chore: Manually fix lint issues in ui.

This commit is contained in:
cpojer 2026-02-02 15:15:30 +09:00
parent 5ba4586e58
commit e9a32b83c2
No known key found for this signature in database
GPG key ID: C29F94A3201118AF
74 changed files with 1552 additions and 600 deletions

View file

@ -36,15 +36,23 @@ export async function handleChannelConfigReload(host: OpenClawApp) {
} }
function parseValidationErrors(details: unknown): Record<string, string> { function parseValidationErrors(details: unknown): Record<string, string> {
if (!Array.isArray(details)) {return {};} if (!Array.isArray(details)) {
return {};
}
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
for (const entry of details) { for (const entry of details) {
if (typeof entry !== "string") {continue;} if (typeof entry !== "string") {
continue;
}
const [rawField, ...rest] = entry.split(":"); const [rawField, ...rest] = entry.split(":");
if (!rawField || rest.length === 0) {continue;} if (!rawField || rest.length === 0) {
continue;
}
const field = rawField.trim(); const field = rawField.trim();
const message = rest.join(":").trim(); const message = rest.join(":").trim();
if (field && message) {errors[field] = message;} if (field && message) {
errors[field] = message;
}
} }
return errors; return errors;
} }
@ -78,7 +86,9 @@ export function handleNostrProfileFieldChange(
value: string, value: string,
) { ) {
const state = host.nostrProfileFormState; const state = host.nostrProfileFormState;
if (!state) {return;} if (!state) {
return;
}
host.nostrProfileFormState = { host.nostrProfileFormState = {
...state, ...state,
values: { values: {
@ -94,7 +104,9 @@ export function handleNostrProfileFieldChange(
export function handleNostrProfileToggleAdvanced(host: OpenClawApp) { export function handleNostrProfileToggleAdvanced(host: OpenClawApp) {
const state = host.nostrProfileFormState; const state = host.nostrProfileFormState;
if (!state) {return;} if (!state) {
return;
}
host.nostrProfileFormState = { host.nostrProfileFormState = {
...state, ...state,
showAdvanced: !state.showAdvanced, showAdvanced: !state.showAdvanced,
@ -103,7 +115,9 @@ export function handleNostrProfileToggleAdvanced(host: OpenClawApp) {
export async function handleNostrProfileSave(host: OpenClawApp) { export async function handleNostrProfileSave(host: OpenClawApp) {
const state = host.nostrProfileFormState; const state = host.nostrProfileFormState;
if (!state || state.saving) {return;} if (!state || state.saving) {
return;
}
const accountId = resolveNostrAccountId(host); const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = { host.nostrProfileFormState = {
@ -172,7 +186,9 @@ export async function handleNostrProfileSave(host: OpenClawApp) {
export async function handleNostrProfileImport(host: OpenClawApp) { export async function handleNostrProfileImport(host: OpenClawApp) {
const state = host.nostrProfileFormState; const state = host.nostrProfileFormState;
if (!state || state.importing) {return;} if (!state || state.importing) {
return;
}
const accountId = resolveNostrAccountId(host); const accountId = resolveNostrAccountId(host);
host.nostrProfileFormState = { host.nostrProfileFormState = {

View file

@ -32,9 +32,13 @@ export function isChatBusy(host: ChatHost) {
export function isChatStopCommand(text: string) { export function isChatStopCommand(text: string) {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) {return false;} if (!trimmed) {
return false;
}
const normalized = trimmed.toLowerCase(); const normalized = trimmed.toLowerCase();
if (normalized === "/stop") {return true;} if (normalized === "/stop") {
return true;
}
return ( return (
normalized === "stop" || normalized === "stop" ||
normalized === "esc" || normalized === "esc" ||
@ -46,14 +50,20 @@ export function isChatStopCommand(text: string) {
function isChatResetCommand(text: string) { function isChatResetCommand(text: string) {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) {return false;} if (!trimmed) {
return false;
}
const normalized = trimmed.toLowerCase(); const normalized = trimmed.toLowerCase();
if (normalized === "/new" || normalized === "/reset") {return true;} if (normalized === "/new" || normalized === "/reset") {
return true;
}
return normalized.startsWith("/new ") || normalized.startsWith("/reset "); return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
} }
export async function handleAbortChat(host: ChatHost) { export async function handleAbortChat(host: ChatHost) {
if (!host.connected) {return;} if (!host.connected) {
return;
}
host.chatMessage = ""; host.chatMessage = "";
await abortChatRun(host as unknown as OpenClawApp); await abortChatRun(host as unknown as OpenClawApp);
} }
@ -66,7 +76,9 @@ function enqueueChatMessage(
) { ) {
const trimmed = text.trim(); const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0); const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) {return;} if (!trimmed && !hasAttachments) {
return;
}
host.chatQueue = [ host.chatQueue = [
...host.chatQueue, ...host.chatQueue,
{ {
@ -123,9 +135,13 @@ async function sendChatMessageNow(
} }
async function flushChatQueue(host: ChatHost) { async function flushChatQueue(host: ChatHost) {
if (!host.connected || isChatBusy(host)) {return;} if (!host.connected || isChatBusy(host)) {
return;
}
const [next, ...rest] = host.chatQueue; const [next, ...rest] = host.chatQueue;
if (!next) {return;} if (!next) {
return;
}
host.chatQueue = rest; host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text, { const ok = await sendChatMessageNow(host, next.text, {
attachments: next.attachments, attachments: next.attachments,
@ -145,7 +161,9 @@ export async function handleSendChat(
messageOverride?: string, messageOverride?: string,
opts?: { restoreDraft?: boolean }, opts?: { restoreDraft?: boolean },
) { ) {
if (!host.connected) {return;} if (!host.connected) {
return;
}
const previousDraft = host.chatMessage; const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim(); const message = (messageOverride ?? host.chatMessage).trim();
const attachments = host.chatAttachments ?? []; const attachments = host.chatAttachments ?? [];
@ -153,7 +171,9 @@ export async function handleSendChat(
const hasAttachments = attachmentsToSend.length > 0; const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required) // Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) {return;} if (!message && !hasAttachments) {
return;
}
if (isChatStopCommand(message)) { if (isChatStopCommand(message)) {
await handleAbortChat(host); await handleAbortChat(host);
@ -201,7 +221,9 @@ type SessionDefaultsSnapshot = {
function resolveAgentIdForSession(host: ChatHost): string | null { function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey); const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) {return parsed.agentId;} if (parsed?.agentId) {
return parsed.agentId;
}
const snapshot = host.hello?.snapshot as const snapshot = host.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot } | { sessionDefaults?: SessionDefaultsSnapshot }
| undefined; | undefined;

View file

@ -64,8 +64,12 @@ function normalizeSessionKeyForDefaults(
): string { ): string {
const raw = (value ?? "").trim(); const raw = (value ?? "").trim();
const mainSessionKey = defaults.mainSessionKey?.trim(); const mainSessionKey = defaults.mainSessionKey?.trim();
if (!mainSessionKey) {return raw;} if (!mainSessionKey) {
if (!raw) {return mainSessionKey;} return raw;
}
if (!raw) {
return mainSessionKey;
}
const mainKey = defaults.mainKey?.trim() || "main"; const mainKey = defaults.mainKey?.trim() || "main";
const defaultAgentId = defaults.defaultAgentId?.trim(); const defaultAgentId = defaults.defaultAgentId?.trim();
const isAlias = const isAlias =
@ -77,7 +81,9 @@ function normalizeSessionKeyForDefaults(
} }
function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) { function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnapshot) {
if (!defaults?.mainSessionKey) {return;} if (!defaults?.mainSessionKey) {
return;
}
const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults); const resolvedSessionKey = normalizeSessionKeyForDefaults(host.sessionKey, defaults);
const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults( const resolvedSettingsSessionKey = normalizeSessionKeyForDefaults(
host.settings.sessionKey, host.settings.sessionKey,
@ -168,7 +174,9 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
} }
if (evt.event === "agent") { if (evt.event === "agent") {
if (host.onboarding) {return;} if (host.onboarding) {
return;
}
handleAgentEvent( handleAgentEvent(
host as unknown as Parameters<typeof handleAgentEvent>[0], host as unknown as Parameters<typeof handleAgentEvent>[0],
evt.payload as AgentEventPayload | undefined, evt.payload as AgentEventPayload | undefined,
@ -198,7 +206,9 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
} }
} }
} }
if (state === "final") {void loadChatHistory(host as unknown as OpenClawApp);} if (state === "final") {
void loadChatHistory(host as unknown as OpenClawApp);
}
return; return;
} }

View file

@ -75,10 +75,7 @@ export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unk
) { ) {
const forcedByTab = changed.has("tab"); const forcedByTab = changed.has("tab");
const forcedByLoad = const forcedByLoad =
changed.has("chatLoading") && changed.has("chatLoading") && changed.get("chatLoading") === true && !host.chatLoading;
changed.get("chatLoading") === true &&
!
host.chatLoading;
scheduleChatScroll( scheduleChatScroll(
host as unknown as Parameters<typeof scheduleChatScroll>[0], host as unknown as Parameters<typeof scheduleChatScroll>[0],
forcedByTab || forcedByLoad || !host.chatHasAutoScrolled, forcedByTab || forcedByLoad || !host.chatHasAutoScrolled,

View file

@ -11,7 +11,9 @@ type PollingHost = {
}; };
export function startNodesPolling(host: PollingHost) { export function startNodesPolling(host: PollingHost) {
if (host.nodesPollInterval != null) {return;} if (host.nodesPollInterval != null) {
return;
}
host.nodesPollInterval = window.setInterval( host.nodesPollInterval = window.setInterval(
() => void loadNodes(host as unknown as OpenClawApp, { quiet: true }), () => void loadNodes(host as unknown as OpenClawApp, { quiet: true }),
5000, 5000,
@ -19,35 +21,49 @@ export function startNodesPolling(host: PollingHost) {
} }
export function stopNodesPolling(host: PollingHost) { export function stopNodesPolling(host: PollingHost) {
if (host.nodesPollInterval == null) {return;} if (host.nodesPollInterval == null) {
return;
}
clearInterval(host.nodesPollInterval); clearInterval(host.nodesPollInterval);
host.nodesPollInterval = null; host.nodesPollInterval = null;
} }
export function startLogsPolling(host: PollingHost) { export function startLogsPolling(host: PollingHost) {
if (host.logsPollInterval != null) {return;} if (host.logsPollInterval != null) {
return;
}
host.logsPollInterval = window.setInterval(() => { host.logsPollInterval = window.setInterval(() => {
if (host.tab !== "logs") {return;} if (host.tab !== "logs") {
return;
}
void loadLogs(host as unknown as OpenClawApp, { quiet: true }); void loadLogs(host as unknown as OpenClawApp, { quiet: true });
}, 2000); }, 2000);
} }
export function stopLogsPolling(host: PollingHost) { export function stopLogsPolling(host: PollingHost) {
if (host.logsPollInterval == null) {return;} if (host.logsPollInterval == null) {
return;
}
clearInterval(host.logsPollInterval); clearInterval(host.logsPollInterval);
host.logsPollInterval = null; host.logsPollInterval = null;
} }
export function startDebugPolling(host: PollingHost) { export function startDebugPolling(host: PollingHost) {
if (host.debugPollInterval != null) {return;} if (host.debugPollInterval != null) {
return;
}
host.debugPollInterval = window.setInterval(() => { host.debugPollInterval = window.setInterval(() => {
if (host.tab !== "debug") {return;} if (host.tab !== "debug") {
return;
}
void loadDebug(host as unknown as OpenClawApp); void loadDebug(host as unknown as OpenClawApp);
}, 3000); }, 3000);
} }
export function stopDebugPolling(host: PollingHost) { export function stopDebugPolling(host: PollingHost) {
if (host.debugPollInterval == null) {return;} if (host.debugPollInterval == null) {
return;
}
clearInterval(host.debugPollInterval); clearInterval(host.debugPollInterval);
host.debugPollInterval = null; host.debugPollInterval = null;
} }

View file

@ -134,7 +134,9 @@ export function renderChatControls(state: AppViewState) {
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}" class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle} ?disabled=${disableThinkingToggle}
@click=${() => { @click=${() => {
if (disableThinkingToggle) {return;} if (disableThinkingToggle) {
return;
}
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatShowThinking: !state.settings.chatShowThinking, chatShowThinking: !state.settings.chatShowThinking,
@ -153,7 +155,9 @@ export function renderChatControls(state: AppViewState) {
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}" class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle} ?disabled=${disableFocusToggle}
@click=${() => { @click=${() => {
if (disableFocusToggle) {return;} if (disableFocusToggle) {
return;
}
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatFocusMode: !state.settings.chatFocusMode, chatFocusMode: !state.settings.chatFocusMode,
@ -183,18 +187,28 @@ function resolveMainSessionKey(
): string | null { ): string | null {
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
if (mainSessionKey) {return mainSessionKey;} if (mainSessionKey) {
return mainSessionKey;
}
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
if (mainKey) {return mainKey;} if (mainKey) {
if (sessions?.sessions?.some((row) => row.key === "main")) {return "main";} return mainKey;
}
if (sessions?.sessions?.some((row) => row.key === "main")) {
return "main";
}
return null; return null;
} }
function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) { function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) {
const label = row?.label?.trim(); const label = row?.label?.trim();
if (label) {return `${label} (${key})`;} if (label) {
return `${label} (${key})`;
}
const displayName = row?.displayName?.trim(); const displayName = row?.displayName?.trim();
if (displayName) {return displayName;} if (displayName) {
return displayName;
}
return key; return key;
} }

View file

@ -1,24 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { AppViewState } from "./app-view-state"; import type { AppViewState } from "./app-view-state";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
ConfigSnapshot,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
LogEntry,
LogLevel,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
StatusSummary,
} from "./types";
import type { ChatQueueItem, CronFormState } from "./ui-types";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { refreshChatAvatar } from "./app-chat"; import { refreshChatAvatar } from "./app-chat";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
@ -63,17 +44,9 @@ import {
saveSkillApiKey, saveSkillApiKey,
updateSkillEdit, updateSkillEdit,
updateSkillEnabled, updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills"; } from "./controllers/skills";
import { icons } from "./icons"; import { icons } from "./icons";
import { import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import { renderChannels } from "./views/channels"; import { renderChannels } from "./views/channels";
import { renderChat } from "./views/chat"; import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config"; import { renderConfig } from "./views/config";
@ -98,8 +71,12 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const agent = list.find((entry) => entry.id === agentId); const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity; const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar; const candidate = identity?.avatarUrl ?? identity?.avatar;
if (!candidate) {return undefined;} if (!candidate) {
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {return candidate;} return undefined;
}
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {
return candidate;
}
return identity?.avatarUrl; return identity?.avatarUrl;
} }
@ -486,7 +463,9 @@ export function renderApp(state: AppViewState) {
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
}, },
onToggleFocusMode: () => { onToggleFocusMode: () => {
if (state.onboarding) {return;} if (state.onboarding) {
return;
}
state.applySettings({ state.applySettings({
...state.settings, ...state.settings,
chatFocusMode: !state.settings.chatFocusMode, chatFocusMode: !state.settings.chatFocusMode,

View file

@ -12,7 +12,9 @@ type ScrollHost = {
}; };
export function scheduleChatScroll(host: ScrollHost, force = false) { export function scheduleChatScroll(host: ScrollHost, force = false) {
if (host.chatScrollFrame) {cancelAnimationFrame(host.chatScrollFrame);} if (host.chatScrollFrame) {
cancelAnimationFrame(host.chatScrollFrame);
}
if (host.chatScrollTimeout != null) { if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout); clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null; host.chatScrollTimeout = null;
@ -25,7 +27,9 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
overflowY === "auto" || overflowY === "auto" ||
overflowY === "scroll" || overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1; container.scrollHeight - container.clientHeight > 1;
if (canScroll) {return container;} if (canScroll) {
return container;
}
} }
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null; return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
}; };
@ -34,22 +38,32 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
host.chatScrollFrame = requestAnimationFrame(() => { host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null; host.chatScrollFrame = null;
const target = pickScrollTarget(); const target = pickScrollTarget();
if (!target) {return;} 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; const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
if (!shouldStick) {return;} if (!shouldStick) {
if (force) {host.chatHasAutoScrolled = true;} return;
}
if (force) {
host.chatHasAutoScrolled = true;
}
target.scrollTop = target.scrollHeight; target.scrollTop = target.scrollHeight;
host.chatUserNearBottom = true; host.chatUserNearBottom = true;
const retryDelay = force ? 150 : 120; const retryDelay = force ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => { host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null; host.chatScrollTimeout = null;
const latest = pickScrollTarget(); const latest = pickScrollTarget();
if (!latest) {return;} if (!latest) {
return;
}
const latestDistanceFromBottom = const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight; latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200; const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
if (!shouldStickRetry) {return;} if (!shouldStickRetry) {
return;
}
latest.scrollTop = latest.scrollHeight; latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true; host.chatUserNearBottom = true;
}, retryDelay); }, retryDelay);
@ -58,16 +72,22 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
} }
export function scheduleLogsScroll(host: ScrollHost, force = false) { export function scheduleLogsScroll(host: ScrollHost, force = false) {
if (host.logsScrollFrame) {cancelAnimationFrame(host.logsScrollFrame);} if (host.logsScrollFrame) {
cancelAnimationFrame(host.logsScrollFrame);
}
void host.updateComplete.then(() => { void host.updateComplete.then(() => {
host.logsScrollFrame = requestAnimationFrame(() => { host.logsScrollFrame = requestAnimationFrame(() => {
host.logsScrollFrame = null; host.logsScrollFrame = null;
const container = host.querySelector(".log-stream") as HTMLElement | null; const container = host.querySelector(".log-stream") as HTMLElement | null;
if (!container) {return;} if (!container) {
return;
}
const distanceFromBottom = const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight; container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldStick = force || distanceFromBottom < 80; const shouldStick = force || distanceFromBottom < 80;
if (!shouldStick) {return;} if (!shouldStick) {
return;
}
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
}); });
}); });
@ -75,14 +95,18 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
export function handleChatScroll(host: ScrollHost, event: Event) { export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null; const container = event.currentTarget as HTMLElement | null;
if (!container) {return;} if (!container) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200; host.chatUserNearBottom = distanceFromBottom < 200;
} }
export function handleLogsScroll(host: ScrollHost, event: Event) { export function handleLogsScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null; const container = event.currentTarget as HTMLElement | null;
if (!container) {return;} if (!container) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80; host.logsAtBottom = distanceFromBottom < 80;
} }
@ -93,7 +117,9 @@ export function resetChatScroll(host: ScrollHost) {
} }
export function exportLogs(lines: string[], label: string) { export function exportLogs(lines: string[], label: string) {
if (lines.length === 0) {return;} if (lines.length === 0) {
return;
}
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" }); const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
@ -105,9 +131,13 @@ export function exportLogs(lines: string[], label: string) {
} }
export function observeTopbar(host: ScrollHost) { export function observeTopbar(host: ScrollHost) {
if (typeof ResizeObserver === "undefined") {return;} if (typeof ResizeObserver === "undefined") {
return;
}
const topbar = host.querySelector(".topbar"); const topbar = host.querySelector(".topbar");
if (!topbar) {return;} if (!topbar) {
return;
}
const update = () => { const update = () => {
const { height } = topbar.getBoundingClientRect(); const { height } = topbar.getBoundingClientRect();
host.style.setProperty("--topbar-height", `${height}px`); host.style.setProperty("--topbar-height", `${height}px`);

View file

@ -64,13 +64,19 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
export function setLastActiveSessionKey(host: SettingsHost, next: string) { export function setLastActiveSessionKey(host: SettingsHost, next: string) {
const trimmed = next.trim(); const trimmed = next.trim();
if (!trimmed) {return;} if (!trimmed) {
if (host.settings.lastActiveSessionKey === trimmed) {return;} return;
}
if (host.settings.lastActiveSessionKey === trimmed) {
return;
}
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed }); applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
} }
export function applySettingsFromUrl(host: SettingsHost) { export function applySettingsFromUrl(host: SettingsHost) {
if (!window.location.search) {return;} if (!window.location.search) {
return;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const tokenRaw = params.get("token"); const tokenRaw = params.get("token");
const passwordRaw = params.get("password"); const passwordRaw = params.get("password");
@ -117,20 +123,31 @@ export function applySettingsFromUrl(host: SettingsHost) {
shouldCleanUrl = true; shouldCleanUrl = true;
} }
if (!shouldCleanUrl) {return;} if (!shouldCleanUrl) {
return;
}
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.search = params.toString(); url.search = params.toString();
window.history.replaceState({}, "", url.toString()); window.history.replaceState({}, "", url.toString());
} }
export function setTab(host: SettingsHost, next: Tab) { export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) {host.tab = next;} if (host.tab !== next) {
if (next === "chat") {host.chatHasAutoScrolled = false;} host.tab = next;
if (next === "logs") {startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);} }
else {stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);} if (next === "chat") {
if (next === "debug") host.chatHasAutoScrolled = false;
{startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);} }
else {stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[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]);
} else {
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
}
void refreshActiveTab(host); void refreshActiveTab(host);
syncUrlWithTab(host, next, false); syncUrlWithTab(host, next, false);
} }
@ -150,12 +167,24 @@ export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTra
} }
export async function refreshActiveTab(host: SettingsHost) { export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "overview") {await loadOverview(host);} if (host.tab === "overview") {
if (host.tab === "channels") {await loadChannelsTab(host);} await loadOverview(host);
if (host.tab === "instances") {await loadPresence(host as unknown as OpenClawApp);} }
if (host.tab === "sessions") {await loadSessions(host as unknown as OpenClawApp);} if (host.tab === "channels") {
if (host.tab === "cron") {await loadCron(host);} await loadChannelsTab(host);
if (host.tab === "skills") {await loadSkills(host as unknown as OpenClawApp);} }
if (host.tab === "instances") {
await loadPresence(host as unknown as OpenClawApp);
}
if (host.tab === "sessions") {
await loadSessions(host as unknown as OpenClawApp);
}
if (host.tab === "cron") {
await loadCron(host);
}
if (host.tab === "skills") {
await loadSkills(host as unknown as OpenClawApp);
}
if (host.tab === "nodes") { if (host.tab === "nodes") {
await loadNodes(host as unknown as OpenClawApp); await loadNodes(host as unknown as OpenClawApp);
await loadDevices(host as unknown as OpenClawApp); await loadDevices(host as unknown as OpenClawApp);
@ -185,7 +214,9 @@ export async function refreshActiveTab(host: SettingsHost) {
} }
export function inferBasePath() { export function inferBasePath() {
if (typeof window === "undefined") {return "";} if (typeof window === "undefined") {
return "";
}
const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__; const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
if (typeof configured === "string" && configured.trim()) { if (typeof configured === "string" && configured.trim()) {
return normalizeBasePath(configured); return normalizeBasePath(configured);
@ -200,17 +231,23 @@ export function syncThemeWithSettings(host: SettingsHost) {
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved; host.themeResolved = resolved;
if (typeof document === "undefined") {return;} if (typeof document === "undefined") {
return;
}
const root = document.documentElement; const root = document.documentElement;
root.dataset.theme = resolved; root.dataset.theme = resolved;
root.style.colorScheme = resolved; root.style.colorScheme = resolved;
} }
export function attachThemeListener(host: SettingsHost) { export function attachThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {return;} if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => { host.themeMediaHandler = (event) => {
if (host.theme !== "system") {return;} if (host.theme !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light"); applyResolvedTheme(host, event.matches ? "dark" : "light");
}; };
if (typeof host.themeMedia.addEventListener === "function") { if (typeof host.themeMedia.addEventListener === "function") {
@ -224,7 +261,9 @@ export function attachThemeListener(host: SettingsHost) {
} }
export function detachThemeListener(host: SettingsHost) { export function detachThemeListener(host: SettingsHost) {
if (!host.themeMedia || !host.themeMediaHandler) {return;} if (!host.themeMedia || !host.themeMediaHandler) {
return;
}
if (typeof host.themeMedia.removeEventListener === "function") { if (typeof host.themeMedia.removeEventListener === "function") {
host.themeMedia.removeEventListener("change", host.themeMediaHandler); host.themeMedia.removeEventListener("change", host.themeMediaHandler);
return; return;
@ -238,16 +277,22 @@ export function detachThemeListener(host: SettingsHost) {
} }
export function syncTabWithLocation(host: SettingsHost, replace: boolean) { export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
if (typeof window === "undefined") {return;} if (typeof window === "undefined") {
return;
}
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat"; const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
setTabFromRoute(host, resolved); setTabFromRoute(host, resolved);
syncUrlWithTab(host, resolved, replace); syncUrlWithTab(host, resolved, replace);
} }
export function onPopState(host: SettingsHost) { export function onPopState(host: SettingsHost) {
if (typeof window === "undefined") {return;} if (typeof window === "undefined") {
return;
}
const resolved = tabFromPath(window.location.pathname, host.basePath); const resolved = tabFromPath(window.location.pathname, host.basePath);
if (!resolved) {return;} if (!resolved) {
return;
}
const url = new URL(window.location.href); const url = new URL(window.location.href);
const session = url.searchParams.get("session")?.trim(); const session = url.searchParams.get("session")?.trim();
@ -264,18 +309,31 @@ export function onPopState(host: SettingsHost) {
} }
export function setTabFromRoute(host: SettingsHost, next: Tab) { export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (host.tab !== next) {host.tab = next;} if (host.tab !== next) {
if (next === "chat") {host.chatHasAutoScrolled = false;} host.tab = next;
if (next === "logs") {startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);} }
else {stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);} if (next === "chat") {
if (next === "debug") host.chatHasAutoScrolled = false;
{startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);} }
else {stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);} if (next === "logs") {
if (host.connected) {void refreshActiveTab(host);} 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]);
} else {
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
}
if (host.connected) {
void refreshActiveTab(host);
}
} }
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) { export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
if (typeof window === "undefined") {return;} if (typeof window === "undefined") {
return;
}
const targetPath = normalizePath(pathForTab(tab, host.basePath)); const targetPath = normalizePath(pathForTab(tab, host.basePath));
const currentPath = normalizePath(window.location.pathname); const currentPath = normalizePath(window.location.pathname);
const url = new URL(window.location.href); const url = new URL(window.location.href);
@ -298,11 +356,16 @@ 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;} if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("session", sessionKey); url.searchParams.set("session", sessionKey);
if (replace) {window.history.replaceState({}, "", url.toString());} if (replace) {
else {window.history.pushState({}, "", url.toString());} window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
} }
export async function loadOverview(host: SettingsHost) { export async function loadOverview(host: SettingsHost) {

View file

@ -35,25 +35,39 @@ type ToolStreamHost = {
}; };
function extractToolOutputText(value: unknown): string | null { function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") {return null;} if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
if (typeof record.text === "string") {return record.text;} if (typeof record.text === "string") {
return record.text;
}
const content = record.content; const content = record.content;
if (!Array.isArray(content)) {return null;} if (!Array.isArray(content)) {
return null;
}
const parts = content const parts = content
.map((item) => { .map((item) => {
if (!item || typeof item !== "object") {return null;} if (!item || typeof item !== "object") {
return null;
}
const entry = item as Record<string, unknown>; const entry = item as Record<string, unknown>;
if (entry.type === "text" && typeof entry.text === "string") {return entry.text;} if (entry.type === "text" && typeof entry.text === "string") {
return entry.text;
}
return null; return null;
}) })
.filter((part): part is string => Boolean(part)); .filter((part): part is string => Boolean(part));
if (parts.length === 0) {return null;} if (parts.length === 0) {
return null;
}
return parts.join("\n"); return parts.join("\n");
} }
function formatToolOutput(value: unknown): string | null { function formatToolOutput(value: unknown): string | null {
if (value === null || value === undefined) {return null;} if (value === null || value === undefined) {
return null;
}
if (typeof value === "number" || typeof value === "boolean") { if (typeof value === "number" || typeof value === "boolean") {
return String(value); return String(value);
} }
@ -67,11 +81,14 @@ function formatToolOutput(value: unknown): string | null {
try { try {
text = JSON.stringify(value, null, 2); text = JSON.stringify(value, null, 2);
} catch { } catch {
// oxlint-disable typescript/no-base-to-string
text = String(value); text = String(value);
} }
} }
const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT); const truncated = truncateText(text, TOOL_OUTPUT_CHAR_LIMIT);
if (!truncated.truncated) {return truncated.text;} if (!truncated.truncated) {
return truncated.text;
}
return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`;
} }
@ -99,10 +116,14 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
} }
function trimToolStream(host: ToolStreamHost) { function trimToolStream(host: ToolStreamHost) {
if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) {return;} if (host.toolStreamOrder.length <= TOOL_STREAM_LIMIT) {
return;
}
const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT; const overflow = host.toolStreamOrder.length - TOOL_STREAM_LIMIT;
const removed = host.toolStreamOrder.splice(0, overflow); const removed = host.toolStreamOrder.splice(0, overflow);
for (const id of removed) {host.toolStreamById.delete(id);} for (const id of removed) {
host.toolStreamById.delete(id);
}
} }
function syncToolStreamMessages(host: ToolStreamHost) { function syncToolStreamMessages(host: ToolStreamHost) {
@ -124,7 +145,9 @@ export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
flushToolStreamSync(host); flushToolStreamSync(host);
return; return;
} }
if (host.toolStreamSyncTimer != null) {return;} if (host.toolStreamSyncTimer != null) {
return;
}
host.toolStreamSyncTimer = window.setTimeout( host.toolStreamSyncTimer = window.setTimeout(
() => flushToolStreamSync(host), () => flushToolStreamSync(host),
TOOL_STREAM_THROTTLE_MS, TOOL_STREAM_THROTTLE_MS,
@ -182,7 +205,9 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
} }
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) { export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) {return;} if (!payload) {
return;
}
// Handle compaction events // Handle compaction events
if (payload.stream === "compaction") { if (payload.stream === "compaction") {
@ -190,17 +215,29 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
return; return;
} }
if (payload.stream !== "tool") {return;} 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;} if (sessionKey && sessionKey !== host.sessionKey) {
return;
}
// Fallback: only accept session-less events for the active run. // Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {return;} if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
if (host.chatRunId && payload.runId !== host.chatRunId) {return;} return;
if (!host.chatRunId) {return;} }
if (host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (!host.chatRunId) {
return;
}
const data = payload.data ?? {}; const data = payload.data ?? {};
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : ""; const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
if (!toolCallId) {return;} if (!toolCallId) {
return;
}
const name = typeof data.name === "string" ? data.name : "tool"; const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : ""; const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined; const args = phase === "start" ? data.args : undefined;
@ -229,8 +266,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
host.toolStreamOrder.push(toolCallId); host.toolStreamOrder.push(toolCallId);
} else { } else {
entry.name = name; entry.name = name;
if (args !== undefined) {entry.args = args;} if (args !== undefined) {
if (output !== undefined) {entry.output = output;} entry.args = args;
}
if (output !== undefined) {
entry.output = output;
}
entry.updatedAt = now; entry.updatedAt = now;
} }

View file

@ -1,4 +1,4 @@
import { LitElement, html, nothing } from "lit"; import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import type { EventLogEntry } from "./app-events"; import type { EventLogEntry } from "./app-events";
import type { DevicePairingList } from "./controllers/devices"; import type { DevicePairingList } from "./controllers/devices";
@ -84,10 +84,14 @@ declare global {
const injectedAssistantIdentity = resolveInjectedAssistantIdentity(); const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
function resolveOnboardingMode(): boolean { function resolveOnboardingMode(): boolean {
if (!window.location.search) {return false;} if (!window.location.search) {
return false;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const raw = params.get("onboarding"); const raw = params.get("onboarding");
if (!raw) {return false;} if (!raw) {
return false;
}
const normalized = raw.trim().toLowerCase(); const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
} }
@ -406,7 +410,9 @@ export class OpenClawApp extends LitElement {
async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") {
const active = this.execApprovalQueue[0]; const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) {return;} if (!active || !this.client || this.execApprovalBusy) {
return;
}
this.execApprovalBusy = true; this.execApprovalBusy = true;
this.execApprovalError = null; this.execApprovalError = null;
try { try {
@ -424,7 +430,9 @@ export class OpenClawApp extends LitElement {
handleGatewayUrlConfirm() { handleGatewayUrlConfirm() {
const nextGatewayUrl = this.pendingGatewayUrl; const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) {return;} if (!nextGatewayUrl) {
return;
}
this.pendingGatewayUrl = null; this.pendingGatewayUrl = null;
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], { applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
...this.settings, ...this.settings,
@ -455,7 +463,9 @@ export class OpenClawApp extends LitElement {
window.clearTimeout(this.sidebarCloseTimer); window.clearTimeout(this.sidebarCloseTimer);
} }
this.sidebarCloseTimer = window.setTimeout(() => { this.sidebarCloseTimer = window.setTimeout(() => {
if (this.sidebarOpen) {return;} if (this.sidebarOpen) {
return;
}
this.sidebarContent = null; this.sidebarContent = null;
this.sidebarError = null; this.sidebarError = null;
this.sidebarCloseTimer = null; this.sidebarCloseTimer = null;

View file

@ -18,10 +18,16 @@ declare global {
} }
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
if (typeof value !== "string") {return undefined;} if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) {return undefined;} if (!trimmed) {
if (trimmed.length <= maxLength) {return trimmed;} return undefined;
}
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.slice(0, maxLength); return trimmed.slice(0, maxLength);
} }

View file

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OpenClawApp } from "./app"; import { OpenClawApp } from "./app";
// oxlint-disable-next-line typescript/unbound-method
const originalConnect = OpenClawApp.prototype.connect; const originalConnect = OpenClawApp.prototype.connect;
function mountApp(pathname: string) { function mountApp(pathname: string) {

View file

@ -13,7 +13,9 @@ type CopyButtonOptions = {
}; };
async function copyTextToClipboard(text: string): Promise<boolean> { async function copyTextToClipboard(text: string): Promise<boolean> {
if (!text) {return false;} if (!text) {
return false;
}
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -38,16 +40,19 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
aria-label=${idleLabel} aria-label=${idleLabel}
@click=${async (e: Event) => { @click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null; const btn = e.currentTarget as HTMLButtonElement | null;
const iconContainer = btn?.querySelector(".chat-copy-btn__icon") as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") {return;} if (!btn || btn.dataset.copying === "1") {
return;
}
btn.dataset.copying = "1"; btn.dataset.copying = "1";
btn.setAttribute("aria-busy", "true"); btn.setAttribute("aria-busy", "true");
btn.disabled = true; btn.disabled = true;
const copied = await copyTextToClipboard(options.text()); const copied = await copyTextToClipboard(options.text());
if (!btn.isConnected) {return;} if (!btn.isConnected) {
return;
}
delete btn.dataset.copying; delete btn.dataset.copying;
btn.removeAttribute("aria-busy"); btn.removeAttribute("aria-busy");
@ -58,7 +63,9 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
setButtonLabel(btn, ERROR_LABEL); setButtonLabel(btn, ERROR_LABEL);
window.setTimeout(() => { window.setTimeout(() => {
if (!btn.isConnected) {return;} if (!btn.isConnected) {
return;
}
delete btn.dataset.error; delete btn.dataset.error;
setButtonLabel(btn, idleLabel); setButtonLabel(btn, idleLabel);
}, ERROR_FOR_MS); }, ERROR_FOR_MS);
@ -69,7 +76,9 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
setButtonLabel(btn, COPIED_LABEL); setButtonLabel(btn, COPIED_LABEL);
window.setTimeout(() => { window.setTimeout(() => {
if (!btn.isConnected) {return;} if (!btn.isConnected) {
return;
}
delete btn.dataset.copied; delete btn.dataset.copied;
setButtonLabel(btn, idleLabel); setButtonLabel(btn, idleLabel);
}, COPIED_FOR_MS); }, COPIED_FOR_MS);

View file

@ -24,7 +24,9 @@ function extractImages(message: unknown): ImageBlock[] {
if (Array.isArray(content)) { if (Array.isArray(content)) {
for (const block of content) { for (const block of content) {
if (typeof block !== "object" || block === null) {continue;} if (typeof block !== "object" || block === null) {
continue;
}
const b = block as Record<string, unknown>; const b = block as Record<string, unknown>;
if (b.type === "image") { if (b.type === "image") {
@ -188,12 +190,14 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
function isAvatarUrl(value: string): boolean { function isAvatarUrl(value: string): boolean {
return ( return (
/^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith('/') // Relative paths from avatar endpoint /^https?:\/\//i.test(value) || /^data:image\//i.test(value) || value.startsWith("/") // Relative paths from avatar endpoint
); );
} }
function renderMessageImages(images: ImageBlock[]) { function renderMessageImages(images: ImageBlock[]) {
if (images.length === 0) {return nothing;} if (images.length === 0) {
return nothing;
}
return html` return html`
<div class="chat-message-images"> <div class="chat-message-images">
@ -251,7 +255,9 @@ function renderGroupedMessage(
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`; return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
} }
if (!markdown && !hasToolCards && !hasImages) {return nothing;} if (!markdown && !hasToolCards && !hasImages) {
return nothing;
}
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">

View file

@ -20,16 +20,24 @@ const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>(); const thinkingCache = new WeakMap<object, string | null>();
function looksLikeEnvelopeHeader(header: string): boolean { function looksLikeEnvelopeHeader(header: string): boolean {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {return true;} if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {return true;} return true;
}
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
return true;
}
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
} }
export function stripEnvelope(text: string): string { export function stripEnvelope(text: string): string {
const match = text.match(ENVELOPE_PREFIX); const match = text.match(ENVELOPE_PREFIX);
if (!match) {return text;} if (!match) {
return text;
}
const header = match[1] ?? ""; const header = match[1] ?? "";
if (!looksLikeEnvelopeHeader(header)) {return text;} if (!looksLikeEnvelopeHeader(header)) {
return text;
}
return text.slice(match[0].length); return text.slice(match[0].length);
} }
@ -45,7 +53,9 @@ export function extractText(message: unknown): string | null {
const parts = content const parts = content
.map((p) => { .map((p) => {
const item = p as Record<string, unknown>; const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") {return item.text;} if (item.type === "text" && typeof item.text === "string") {
return item.text;
}
return null; return null;
}) })
.filter((v): v is string => typeof v === "string"); .filter((v): v is string => typeof v === "string");
@ -63,9 +73,13 @@ export function extractText(message: unknown): string | null {
} }
export function extractTextCached(message: unknown): string | null { export function extractTextCached(message: unknown): string | null {
if (!message || typeof message !== "object") {return extractText(message);} if (!message || typeof message !== "object") {
return extractText(message);
}
const obj = message; const obj = message;
if (textCache.has(obj)) {return textCache.get(obj) ?? null;} if (textCache.has(obj)) {
return textCache.get(obj) ?? null;
}
const value = extractText(message); const value = extractText(message);
textCache.set(obj, value); textCache.set(obj, value);
return value; return value;
@ -80,15 +94,21 @@ export function extractThinking(message: unknown): string | null {
const item = p as Record<string, unknown>; const item = p as Record<string, unknown>;
if (item.type === "thinking" && typeof item.thinking === "string") { if (item.type === "thinking" && typeof item.thinking === "string") {
const cleaned = item.thinking.trim(); const cleaned = item.thinking.trim();
if (cleaned) {parts.push(cleaned);} if (cleaned) {
parts.push(cleaned);
}
} }
} }
} }
if (parts.length > 0) {return parts.join("\n");} if (parts.length > 0) {
return parts.join("\n");
}
// Back-compat: older logs may still have <think> tags inside text blocks. // Back-compat: older logs may still have <think> tags inside text blocks.
const rawText = extractRawText(message); const rawText = extractRawText(message);
if (!rawText) {return null;} if (!rawText) {
return null;
}
const matches = [ 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),
]; ];
@ -97,9 +117,13 @@ export function extractThinking(message: unknown): string | null {
} }
export function extractThinkingCached(message: unknown): string | null { export function extractThinkingCached(message: unknown): string | null {
if (!message || typeof message !== "object") {return extractThinking(message);} if (!message || typeof message !== "object") {
return extractThinking(message);
}
const obj = message; const obj = message;
if (thinkingCache.has(obj)) {return thinkingCache.get(obj) ?? null;} if (thinkingCache.has(obj)) {
return thinkingCache.get(obj) ?? null;
}
const value = extractThinking(message); const value = extractThinking(message);
thinkingCache.set(obj, value); thinkingCache.set(obj, value);
return value; return value;
@ -108,24 +132,34 @@ export function extractThinkingCached(message: unknown): string | null {
export function extractRawText(message: unknown): string | null { export function extractRawText(message: unknown): string | null {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const content = m.content; const content = m.content;
if (typeof content === "string") {return content;} if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) { if (Array.isArray(content)) {
const parts = content const parts = content
.map((p) => { .map((p) => {
const item = p as Record<string, unknown>; const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") {return item.text;} if (item.type === "text" && typeof item.text === "string") {
return item.text;
}
return null; return null;
}) })
.filter((v): v is string => typeof v === "string"); .filter((v): v is string => typeof v === "string");
if (parts.length > 0) {return parts.join("\n");} if (parts.length > 0) {
return parts.join("\n");
}
}
if (typeof m.text === "string") {
return m.text;
} }
if (typeof m.text === "string") {return m.text;}
return null; return null;
} }
export function formatReasoningMarkdown(text: string): string { export function formatReasoningMarkdown(text: string): string {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) {return "";} if (!trimmed) {
return "";
}
const lines = trimmed const lines = trimmed
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim())

View file

@ -21,13 +21,11 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
Array.isArray(contentItems) && Array.isArray(contentItems) &&
contentItems.some((item) => { contentItems.some((item) => {
const x = item as Record<string, unknown>; const x = item as Record<string, unknown>;
const t = String(x.type ?? "").toLowerCase(); const t = (typeof x.type === "string" ? x.type : "").toLowerCase();
return t === "toolresult" || t === "tool_result"; return t === "toolresult" || t === "tool_result";
}); });
const hasToolName = const hasToolName = typeof m.toolName === "string" || typeof m.tool_name === "string";
typeof (m).toolName === "string" ||
typeof (m).tool_name === "string";
if (hasToolId || hasToolContent || hasToolName) { if (hasToolId || hasToolContent || hasToolName) {
role = "toolResult"; role = "toolResult";
@ -61,9 +59,15 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
export function normalizeRoleForGrouping(role: string): string { export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase(); const lower = role.toLowerCase();
// Preserve original casing when it's already a core role. // Preserve original casing when it's already a core role.
if (role === "user" || role === "User") {return role;} if (role === "user" || role === "User") {
if (role === "assistant") {return "assistant";} return role;
if (role === "system") {return "system";} }
if (role === "assistant") {
return "assistant";
}
if (role === "system") {
return "system";
}
// Keep tool-related roles distinct so the UI can style/toggle them. // Keep tool-related roles distinct so the UI can style/toggle them.
if ( if (
lower === "toolresult" || lower === "toolresult" ||

View file

@ -13,7 +13,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
const cards: ToolCard[] = []; const cards: ToolCard[] = [];
for (const item of content) { for (const item of content) {
const kind = String(item.type ?? "").toLowerCase(); const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
const isToolCall = const isToolCall =
["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) || ["toolcall", "tool_call", "tooluse", "tool_use"].includes(kind) ||
(typeof item.name === "string" && item.arguments != null); (typeof item.name === "string" && item.arguments != null);
@ -27,8 +27,10 @@ export function extractToolCards(message: unknown): ToolCard[] {
} }
for (const item of content) { for (const item of content) {
const kind = String(item.type ?? "").toLowerCase(); const kind = (typeof item.type === "string" ? item.type : "").toLowerCase();
if (kind !== "toolresult" && kind !== "tool_result") {continue;} if (kind !== "toolresult" && kind !== "tool_result") {
continue;
}
const text = extractToolText(item); const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool"; const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text }); cards.push({ kind: "result", name, text });
@ -79,7 +81,9 @@ export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content:
@keydown=${ @keydown=${
canClick canClick
? (e: KeyboardEvent) => { ? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {return;} if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault(); e.preventDefault();
handleClick?.(); handleClick?.();
} }
@ -117,15 +121,23 @@ export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content:
} }
function normalizeContent(content: unknown): Array<Record<string, unknown>> { function normalizeContent(content: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(content)) {return [];} if (!Array.isArray(content)) {
return [];
}
return content.filter(Boolean) as Array<Record<string, unknown>>; return content.filter(Boolean) as Array<Record<string, unknown>>;
} }
function coerceArgs(value: unknown): unknown { function coerceArgs(value: unknown): unknown {
if (typeof value !== "string") {return value;} if (typeof value !== "string") {
return value;
}
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) {return value;} if (!trimmed) {
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {return value;} return value;
}
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return value;
}
try { try {
return JSON.parse(trimmed); return JSON.parse(trimmed);
} catch { } catch {
@ -134,7 +146,11 @@ function coerceArgs(value: unknown): unknown {
} }
function extractToolText(item: Record<string, unknown>): string | undefined { function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.text === "string") {return item.text;} if (typeof item.text === "string") {
if (typeof item.content === "string") {return item.content;} return item.text;
}
if (typeof item.content === "string") {
return item.content;
}
return undefined; return undefined;
} }

View file

@ -74,10 +74,14 @@ export class ResizableDivider extends LitElement {
}; };
private handleMouseMove = (e: MouseEvent) => { private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) {return;} if (!this.isDragging) {
return;
}
const container = this.parentElement; const container = this.parentElement;
if (!container) {return;} if (!container) {
return;
}
const containerWidth = container.getBoundingClientRect().width; const containerWidth = container.getBoundingClientRect().width;
const deltaX = e.clientX - this.startX; const deltaX = e.clientX - this.startX;

View file

@ -53,7 +53,9 @@ describe("config form renderer", () => {
const tokenInput = container.querySelector("input[type='password']"); const tokenInput = container.querySelector("input[type='password']");
expect(tokenInput).not.toBeNull(); expect(tokenInput).not.toBeNull();
if (!tokenInput) {return;} if (!tokenInput) {
return;
}
tokenInput.value = "abc123"; tokenInput.value = "abc123";
tokenInput.dispatchEvent(new Event("input", { bubbles: true })); tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123"); expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
@ -67,7 +69,9 @@ describe("config form renderer", () => {
const checkbox = container.querySelector("input[type='checkbox']"); const checkbox = container.querySelector("input[type='checkbox']");
expect(checkbox).not.toBeNull(); expect(checkbox).not.toBeNull();
if (!checkbox) {return;} if (!checkbox) {
return;
}
checkbox.checked = true; checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true })); checkbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["enabled"], true); expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
@ -93,9 +97,7 @@ describe("config form renderer", () => {
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
const removeButton = container.querySelector( const removeButton = container.querySelector(".cfg-array__item-remove");
".cfg-array__item-remove",
);
expect(removeButton).not.toBeUndefined(); expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
@ -150,9 +152,7 @@ describe("config form renderer", () => {
container, container,
); );
const removeButton = container.querySelector( const removeButton = container.querySelector(".cfg-map__item-remove");
".cfg-map__item-remove",
);
expect(removeButton).not.toBeUndefined(); expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["slack"], {}); expect(onPatch).toHaveBeenCalledWith(["slack"], {});

View file

@ -10,13 +10,19 @@ export type AgentsState = {
}; };
export async function loadAgents(state: AgentsState) { export async function loadAgents(state: AgentsState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.agentsLoading) {return;} return;
}
if (state.agentsLoading) {
return;
}
state.agentsLoading = true; state.agentsLoading = true;
state.agentsError = null; state.agentsError = null;
try { try {
const res = (await state.client.request("agents.list", {})); const res = await state.client.request("agents.list", {});
if (res) {state.agentsList = res;} if (res) {
state.agentsList = res;
}
} catch (err) { } catch (err) {
state.agentsError = String(err); state.agentsError = String(err);
} finally { } finally {

View file

@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import { normalizeAssistantIdentity, type AssistantIdentity } from "../assistant-identity"; import { normalizeAssistantIdentity } from "../assistant-identity";
export type AssistantIdentityState = { export type AssistantIdentityState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
@ -14,12 +14,16 @@ export async function loadAssistantIdentity(
state: AssistantIdentityState, state: AssistantIdentityState,
opts?: { sessionKey?: string }, opts?: { sessionKey?: string },
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim(); const sessionKey = opts?.sessionKey?.trim() || state.sessionKey.trim();
const params = sessionKey ? { sessionKey } : {}; const params = sessionKey ? { sessionKey } : {};
try { try {
const res = (await state.client.request("agent.identity.get", params)); const res = await state.client.request("agent.identity.get", params);
if (!res) {return;} if (!res) {
return;
}
const normalized = normalizeAssistantIdentity(res); const normalized = normalizeAssistantIdentity(res);
state.assistantName = normalized.name; state.assistantName = normalized.name;
state.assistantAvatar = normalized.avatar; state.assistantAvatar = normalized.avatar;

View file

@ -1,18 +1,21 @@
import type { ChannelsStatusSnapshot } from "../types";
import type { ChannelsState } from "./channels.types"; import type { ChannelsState } from "./channels.types";
export type { ChannelsState }; export type { ChannelsState };
export async function loadChannels(state: ChannelsState, probe: boolean) { export async function loadChannels(state: ChannelsState, probe: boolean) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.channelsLoading) {return;} return;
}
if (state.channelsLoading) {
return;
}
state.channelsLoading = true; state.channelsLoading = true;
state.channelsError = null; state.channelsError = null;
try { try {
const res = (await state.client.request("channels.status", { const res = await state.client.request("channels.status", {
probe, probe,
timeoutMs: 8000, timeoutMs: 8000,
})); });
state.channelsSnapshot = res; state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now(); state.channelsLastSuccess = Date.now();
} catch (err) { } catch (err) {
@ -23,13 +26,15 @@ export async function loadChannels(state: ChannelsState, probe: boolean) {
} }
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) { export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
if (!state.client || !state.connected || state.whatsappBusy) {return;} if (!state.client || !state.connected || state.whatsappBusy) {
return;
}
state.whatsappBusy = true; state.whatsappBusy = true;
try { try {
const res = (await state.client.request("web.login.start", { const res = await state.client.request("web.login.start", {
force, force,
timeoutMs: 30000, timeoutMs: 30000,
})); });
state.whatsappLoginMessage = res.message ?? null; state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null; state.whatsappLoginQrDataUrl = res.qrDataUrl ?? null;
state.whatsappLoginConnected = null; state.whatsappLoginConnected = null;
@ -43,15 +48,19 @@ export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {
} }
export async function waitWhatsAppLogin(state: ChannelsState) { export async function waitWhatsAppLogin(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) {return;} if (!state.client || !state.connected || state.whatsappBusy) {
return;
}
state.whatsappBusy = true; state.whatsappBusy = true;
try { try {
const res = (await state.client.request("web.login.wait", { const res = await state.client.request("web.login.wait", {
timeoutMs: 120000, timeoutMs: 120000,
})); });
state.whatsappLoginMessage = res.message ?? null; state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null; state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) {state.whatsappLoginQrDataUrl = null;} if (res.connected) {
state.whatsappLoginQrDataUrl = null;
}
} catch (err) { } catch (err) {
state.whatsappLoginMessage = String(err); state.whatsappLoginMessage = String(err);
state.whatsappLoginConnected = null; state.whatsappLoginConnected = null;
@ -61,7 +70,9 @@ export async function waitWhatsAppLogin(state: ChannelsState) {
} }
export async function logoutWhatsApp(state: ChannelsState) { export async function logoutWhatsApp(state: ChannelsState) {
if (!state.client || !state.connected || state.whatsappBusy) {return;} if (!state.client || !state.connected || state.whatsappBusy) {
return;
}
state.whatsappBusy = true; state.whatsappBusy = true;
try { try {
await state.client.request("channels.logout", { channel: "whatsapp" }); await state.client.request("channels.logout", { channel: "whatsapp" });

View file

@ -28,14 +28,16 @@ export type ChatEventPayload = {
}; };
export async function loadChatHistory(state: ChatState) { export async function loadChatHistory(state: ChatState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.chatLoading = true; state.chatLoading = true;
state.lastError = null; state.lastError = null;
try { try {
const res = (await state.client.request("chat.history", { const res = await state.client.request("chat.history", {
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
limit: 200, limit: 200,
})); });
state.chatMessages = Array.isArray(res.messages) ? res.messages : []; state.chatMessages = Array.isArray(res.messages) ? res.messages : [];
state.chatThinkingLevel = res.thinkingLevel ?? null; state.chatThinkingLevel = res.thinkingLevel ?? null;
} catch (err) { } catch (err) {
@ -47,7 +49,9 @@ export async function loadChatHistory(state: ChatState) {
function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null { function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl); const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
if (!match) {return null;} if (!match) {
return null;
}
return { mimeType: match[1], content: match[2] }; return { mimeType: match[1], content: match[2] };
} }
@ -56,10 +60,14 @@ export async function sendChatMessage(
message: string, message: string,
attachments?: ChatAttachment[], attachments?: ChatAttachment[],
): Promise<string | null> { ): Promise<string | null> {
if (!state.client || !state.connected) {return null;} if (!state.client || !state.connected) {
return null;
}
const msg = message.trim(); const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {return null;} if (!msg && !hasAttachments) {
return null;
}
const now = Date.now(); const now = Date.now();
@ -99,7 +107,9 @@ export async function sendChatMessage(
? attachments ? attachments
.map((att) => { .map((att) => {
const parsed = dataUrlToBase64(att.dataUrl); const parsed = dataUrlToBase64(att.dataUrl);
if (!parsed) {return null;} if (!parsed) {
return null;
}
return { return {
type: "image", type: "image",
mimeType: parsed.mimeType, mimeType: parsed.mimeType,
@ -139,7 +149,9 @@ export async function sendChatMessage(
} }
export async function abortChatRun(state: ChatState): Promise<boolean> { export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) {return false;} if (!state.client || !state.connected) {
return false;
}
const runId = state.chatRunId; const runId = state.chatRunId;
try { try {
await state.client.request( await state.client.request(
@ -154,13 +166,19 @@ 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) {
if (payload.sessionKey !== state.sessionKey) {return null;} return null;
}
if (payload.sessionKey !== state.sessionKey) {
return null;
}
// Final from another run (e.g. sub-agent announce): refresh history to show new message. // Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/openclaw/openclaw/issues/1909 // 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";} if (payload.state === "final") {
return "final";
}
return null; return null;
} }

View file

@ -35,11 +35,13 @@ export type ConfigState = {
}; };
export async function loadConfig(state: ConfigState) { export async function loadConfig(state: ConfigState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.configLoading = true; state.configLoading = true;
state.lastError = null; state.lastError = null;
try { try {
const res = (await state.client.request("config.get", {})); const res = await state.client.request("config.get", {});
applyConfigSnapshot(state, res); applyConfigSnapshot(state, res);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
@ -49,11 +51,15 @@ export async function loadConfig(state: ConfigState) {
} }
export async function loadConfigSchema(state: ConfigState) { export async function loadConfigSchema(state: ConfigState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.configSchemaLoading) {return;} return;
}
if (state.configSchemaLoading) {
return;
}
state.configSchemaLoading = true; state.configSchemaLoading = true;
try { try {
const res = (await state.client.request("config.schema", {})); const res = await state.client.request("config.schema", {});
applyConfigSchema(state, res); applyConfigSchema(state, res);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
@ -94,7 +100,9 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
} }
export async function saveConfig(state: ConfigState) { export async function saveConfig(state: ConfigState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.configSaving = true; state.configSaving = true;
state.lastError = null; state.lastError = null;
try { try {
@ -118,7 +126,9 @@ export async function saveConfig(state: ConfigState) {
} }
export async function applyConfig(state: ConfigState) { export async function applyConfig(state: ConfigState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.configApplying = true; state.configApplying = true;
state.lastError = null; state.lastError = null;
try { try {
@ -146,7 +156,9 @@ export async function applyConfig(state: ConfigState) {
} }
export async function runUpdate(state: ConfigState) { export async function runUpdate(state: ConfigState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.updateRunning = true; state.updateRunning = true;
state.lastError = null; state.lastError = null;
try { try {

View file

@ -14,19 +14,25 @@ export function setPathValue(
path: Array<string | number>, path: Array<string | number>,
value: unknown, value: unknown,
) { ) {
if (path.length === 0) {return;} if (path.length === 0) {
return;
}
let current: Record<string, unknown> | unknown[] = obj; let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) { for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i]; const key = path[i];
const nextKey = path[i + 1]; const nextKey = path[i + 1];
if (typeof key === "number") { if (typeof key === "number") {
if (!Array.isArray(current)) {return;} if (!Array.isArray(current)) {
return;
}
if (current[key] == null) { 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[]; current = current[key] as Record<string, unknown> | unknown[];
} else { } else {
if (typeof current !== "object" || current == null) {return;} if (typeof current !== "object" || current == null) {
return;
}
const record = current as Record<string, unknown>; const record = current as Record<string, unknown>;
if (record[key] == null) { if (record[key] == null) {
record[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>); record[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
@ -36,7 +42,9 @@ export function setPathValue(
} }
const lastKey = path[path.length - 1]; const lastKey = path[path.length - 1];
if (typeof lastKey === "number") { if (typeof lastKey === "number") {
if (Array.isArray(current)) {current[lastKey] = value;} if (Array.isArray(current)) {
current[lastKey] = value;
}
return; return;
} }
if (typeof current === "object" && current != null) { if (typeof current === "object" && current != null) {
@ -48,22 +56,32 @@ export function removePathValue(
obj: Record<string, unknown> | unknown[], obj: Record<string, unknown> | unknown[],
path: Array<string | number>, path: Array<string | number>,
) { ) {
if (path.length === 0) {return;} if (path.length === 0) {
return;
}
let current: Record<string, unknown> | unknown[] = obj; let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) { for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i]; const key = path[i];
if (typeof key === "number") { if (typeof key === "number") {
if (!Array.isArray(current)) {return;} if (!Array.isArray(current)) {
return;
}
current = current[key] as Record<string, unknown> | unknown[]; current = current[key] as Record<string, unknown> | unknown[];
} else { } else {
if (typeof current !== "object" || current == null) {return;} 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;} if (current == null) {
return;
}
} }
const lastKey = path[path.length - 1]; const lastKey = path[path.length - 1];
if (typeof lastKey === "number") { if (typeof lastKey === "number") {
if (Array.isArray(current)) {current.splice(lastKey, 1);} if (Array.isArray(current)) {
current.splice(lastKey, 1);
}
return; return;
} }
if (typeof current === "object" && current != null) { if (typeof current === "object" && current != null) {

View file

@ -17,9 +17,11 @@ export type CronState = {
}; };
export async function loadCronStatus(state: CronState) { export async function loadCronStatus(state: CronState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
try { try {
const res = (await state.client.request("cron.status", {})); const res = await state.client.request("cron.status", {});
state.cronStatus = res; state.cronStatus = res;
} catch (err) { } catch (err) {
state.cronError = String(err); state.cronError = String(err);
@ -27,14 +29,18 @@ export async function loadCronStatus(state: CronState) {
} }
export async function loadCronJobs(state: CronState) { export async function loadCronJobs(state: CronState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.cronLoading) {return;} return;
}
if (state.cronLoading) {
return;
}
state.cronLoading = true; state.cronLoading = true;
state.cronError = null; state.cronError = null;
try { try {
const res = (await state.client.request("cron.list", { const res = await state.client.request("cron.list", {
includeDisabled: true, includeDisabled: true,
})); });
state.cronJobs = Array.isArray(res.jobs) ? res.jobs : []; state.cronJobs = Array.isArray(res.jobs) ? res.jobs : [];
} catch (err) { } catch (err) {
state.cronError = String(err); state.cronError = String(err);
@ -46,29 +52,39 @@ export async function loadCronJobs(state: CronState) {
export function buildCronSchedule(form: CronFormState) { export function buildCronSchedule(form: CronFormState) {
if (form.scheduleKind === "at") { if (form.scheduleKind === "at") {
const ms = Date.parse(form.scheduleAt); const ms = Date.parse(form.scheduleAt);
if (!Number.isFinite(ms)) {throw new Error("Invalid run time.");} if (!Number.isFinite(ms)) {
throw new Error("Invalid run time.");
}
return { kind: "at" as const, atMs: ms }; return { kind: "at" as const, atMs: ms };
} }
if (form.scheduleKind === "every") { if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0); const amount = toNumber(form.everyAmount, 0);
if (amount <= 0) {throw new Error("Invalid interval amount.");} if (amount <= 0) {
throw new Error("Invalid interval amount.");
}
const unit = form.everyUnit; const unit = form.everyUnit;
const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000; const mult = unit === "minutes" ? 60_000 : unit === "hours" ? 3_600_000 : 86_400_000;
return { kind: "every" as const, everyMs: amount * mult }; return { kind: "every" as const, everyMs: amount * mult };
} }
const expr = form.cronExpr.trim(); const expr = form.cronExpr.trim();
if (!expr) {throw new Error("Cron expression required.");} if (!expr) {
throw new Error("Cron expression required.");
}
return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined };
} }
export function buildCronPayload(form: CronFormState) { export function buildCronPayload(form: CronFormState) {
if (form.payloadKind === "systemEvent") { if (form.payloadKind === "systemEvent") {
const text = form.payloadText.trim(); const text = form.payloadText.trim();
if (!text) {throw new Error("System event text required.");} if (!text) {
throw new Error("System event text required.");
}
return { kind: "systemEvent" as const, text }; return { kind: "systemEvent" as const, text };
} }
const message = form.payloadText.trim(); const message = form.payloadText.trim();
if (!message) {throw new Error("Agent message required.");} if (!message) {
throw new Error("Agent message required.");
}
const payload: { const payload: {
kind: "agentTurn"; kind: "agentTurn";
message: string; message: string;
@ -77,16 +93,26 @@ export function buildCronPayload(form: CronFormState) {
to?: string; to?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
} = { kind: "agentTurn", message }; } = { kind: "agentTurn", message };
if (form.deliver) {payload.deliver = true;} if (form.deliver) {
if (form.channel) {payload.channel = form.channel;} payload.deliver = true;
if (form.to.trim()) {payload.to = form.to.trim();} }
if (form.channel) {
payload.channel = form.channel;
}
if (form.to.trim()) {
payload.to = form.to.trim();
}
const timeoutSeconds = toNumber(form.timeoutSeconds, 0); const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
if (timeoutSeconds > 0) {payload.timeoutSeconds = timeoutSeconds;} if (timeoutSeconds > 0) {
payload.timeoutSeconds = timeoutSeconds;
}
return payload; return payload;
} }
export async function addCronJob(state: CronState) { export async function addCronJob(state: CronState) {
if (!state.client || !state.connected || state.cronBusy) {return;} if (!state.client || !state.connected || state.cronBusy) {
return;
}
state.cronBusy = true; state.cronBusy = true;
state.cronError = null; state.cronError = null;
try { try {
@ -107,7 +133,9 @@ export async function addCronJob(state: CronState) {
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() } ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
: undefined, : undefined,
}; };
if (!job.name) {throw new Error("Name required.");} if (!job.name) {
throw new Error("Name required.");
}
await state.client.request("cron.add", job); await state.client.request("cron.add", job);
state.cronForm = { state.cronForm = {
...state.cronForm, ...state.cronForm,
@ -125,7 +153,9 @@ 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;} if (!state.client || !state.connected || state.cronBusy) {
return;
}
state.cronBusy = true; state.cronBusy = true;
state.cronError = null; state.cronError = null;
try { try {
@ -140,7 +170,9 @@ export async function toggleCronJob(state: CronState, job: CronJob, enabled: boo
} }
export async function runCronJob(state: CronState, job: CronJob) { export async function runCronJob(state: CronState, job: CronJob) {
if (!state.client || !state.connected || state.cronBusy) {return;} if (!state.client || !state.connected || state.cronBusy) {
return;
}
state.cronBusy = true; state.cronBusy = true;
state.cronError = null; state.cronError = null;
try { try {
@ -154,7 +186,9 @@ export async function runCronJob(state: CronState, job: CronJob) {
} }
export async function removeCronJob(state: CronState, job: CronJob) { export async function removeCronJob(state: CronState, job: CronJob) {
if (!state.client || !state.connected || state.cronBusy) {return;} if (!state.client || !state.connected || state.cronBusy) {
return;
}
state.cronBusy = true; state.cronBusy = true;
state.cronError = null; state.cronError = null;
try { try {
@ -173,12 +207,14 @@ export async function removeCronJob(state: CronState, job: CronJob) {
} }
export async function loadCronRuns(state: CronState, jobId: string) { export async function loadCronRuns(state: CronState, jobId: string) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
try { try {
const res = (await state.client.request("cron.runs", { const res = await state.client.request("cron.runs", {
id: jobId, id: jobId,
limit: 50, limit: 50,
})); });
state.cronRunsJobId = jobId; state.cronRunsJobId = jobId;
state.cronRuns = Array.isArray(res.entries) ? res.entries : []; state.cronRuns = Array.isArray(res.entries) ? res.entries : [];
} catch (err) { } catch (err) {

View file

@ -16,8 +16,12 @@ export type DebugState = {
}; };
export async function loadDebug(state: DebugState) { export async function loadDebug(state: DebugState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.debugLoading) {return;} return;
}
if (state.debugLoading) {
return;
}
state.debugLoading = true; state.debugLoading = true;
try { try {
const [status, health, models, heartbeat] = await Promise.all([ const [status, health, models, heartbeat] = await Promise.all([
@ -39,7 +43,9 @@ export async function loadDebug(state: DebugState) {
} }
export async function callDebugMethod(state: DebugState) { export async function callDebugMethod(state: DebugState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.debugCallError = null; state.debugCallError = null;
state.debugCallResult = null; state.debugCallResult = null;
try { try {

View file

@ -46,25 +46,35 @@ export type DevicesState = {
}; };
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) { export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.devicesLoading) {return;} return;
}
if (state.devicesLoading) {
return;
}
state.devicesLoading = true; state.devicesLoading = true;
if (!opts?.quiet) {state.devicesError = null;} if (!opts?.quiet) {
state.devicesError = null;
}
try { try {
const res = (await state.client.request("device.pair.list", {})); const res = await state.client.request("device.pair.list", {});
state.devicesList = { state.devicesList = {
pending: Array.isArray(res?.pending) ? res.pending : [], pending: Array.isArray(res?.pending) ? res.pending : [],
paired: Array.isArray(res?.paired) ? res.paired : [], paired: Array.isArray(res?.paired) ? res.paired : [],
}; };
} catch (err) { } catch (err) {
if (!opts?.quiet) {state.devicesError = String(err);} if (!opts?.quiet) {
state.devicesError = String(err);
}
} finally { } finally {
state.devicesLoading = false; state.devicesLoading = false;
} }
} }
export async function approveDevicePairing(state: DevicesState, requestId: string) { export async function approveDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
try { try {
await state.client.request("device.pair.approve", { requestId }); await state.client.request("device.pair.approve", { requestId });
await loadDevices(state); await loadDevices(state);
@ -74,9 +84,13 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
} }
export async function rejectDevicePairing(state: DevicesState, requestId: string) { export async function rejectDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
const confirmed = window.confirm("Reject this device pairing request?"); const confirmed = window.confirm("Reject this device pairing request?");
if (!confirmed) {return;} if (!confirmed) {
return;
}
try { try {
await state.client.request("device.pair.reject", { requestId }); await state.client.request("device.pair.reject", { requestId });
await loadDevices(state); await loadDevices(state);
@ -89,9 +103,11 @@ export async function rotateDeviceToken(
state: DevicesState, state: DevicesState,
params: { deviceId: string; role: string; scopes?: string[] }, params: { deviceId: string; role: string; scopes?: string[] },
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
try { try {
const res = (await state.client.request("device.token.rotate", params)); const res = await state.client.request("device.token.rotate", params);
if (res?.token) { if (res?.token) {
const identity = await loadOrCreateDeviceIdentity(); const identity = await loadOrCreateDeviceIdentity();
const role = res.role ?? params.role; const role = res.role ?? params.role;
@ -115,9 +131,13 @@ export async function revokeDeviceToken(
state: DevicesState, state: DevicesState,
params: { deviceId: string; role: string }, params: { deviceId: string; role: string },
) { ) {
if (!state.client || !state.connected) {return;} 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;} if (!confirmed) {
return;
}
try { try {
await state.client.request("device.token.revoke", params); await state.client.request("device.token.revoke", params);
const identity = await loadOrCreateDeviceIdentity(); const identity = await loadOrCreateDeviceIdentity();

View file

@ -28,15 +28,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null { export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null {
if (!isRecord(payload)) {return null;} if (!isRecord(payload)) {
return null;
}
const id = typeof payload.id === "string" ? payload.id.trim() : ""; const id = typeof payload.id === "string" ? payload.id.trim() : "";
const request = payload.request; const request = payload.request;
if (!id || !isRecord(request)) {return null;} if (!id || !isRecord(request)) {
return null;
}
const command = typeof request.command === "string" ? request.command.trim() : ""; const command = typeof request.command === "string" ? request.command.trim() : "";
if (!command) {return null;} if (!command) {
return null;
}
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0; const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0; const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
if (!createdAtMs || !expiresAtMs) {return null;} if (!createdAtMs || !expiresAtMs) {
return null;
}
return { return {
id, id,
request: { request: {
@ -55,9 +63,13 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques
} }
export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null { export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null {
if (!isRecord(payload)) {return null;} if (!isRecord(payload)) {
return null;
}
const id = typeof payload.id === "string" ? payload.id.trim() : ""; const id = typeof payload.id === "string" ? payload.id.trim() : "";
if (!id) {return null;} if (!id) {
return null;
}
return { return {
id, id,
decision: typeof payload.decision === "string" ? payload.decision : null, decision: typeof payload.decision === "string" ? payload.decision : null,

View file

@ -56,7 +56,9 @@ function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
return { method: "exec.approvals.get", params: {} }; return { method: "exec.approvals.get", params: {} };
} }
const nodeId = target.nodeId.trim(); const nodeId = target.nodeId.trim();
if (!nodeId) {return null;} if (!nodeId) {
return null;
}
return { method: "exec.approvals.node.get", params: { nodeId } }; return { method: "exec.approvals.node.get", params: { nodeId } };
} }
@ -68,7 +70,9 @@ function resolveExecApprovalsSaveRpc(
return { method: "exec.approvals.set", params }; return { method: "exec.approvals.set", params };
} }
const nodeId = target.nodeId.trim(); const nodeId = target.nodeId.trim();
if (!nodeId) {return null;} if (!nodeId) {
return null;
}
return { method: "exec.approvals.node.set", params: { ...params, nodeId } }; return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
} }
@ -76,8 +80,12 @@ export async function loadExecApprovals(
state: ExecApprovalsState, state: ExecApprovalsState,
target?: ExecApprovalsTarget | null, target?: ExecApprovalsTarget | null,
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.execApprovalsLoading) {return;} return;
}
if (state.execApprovalsLoading) {
return;
}
state.execApprovalsLoading = true; state.execApprovalsLoading = true;
state.lastError = null; state.lastError = null;
try { try {
@ -86,7 +94,7 @@ export async function loadExecApprovals(
state.lastError = "Select a node before loading exec approvals."; state.lastError = "Select a node before loading exec approvals.";
return; return;
} }
const res = (await state.client.request(rpc.method, rpc.params)); const res = await state.client.request(rpc.method, rpc.params);
applyExecApprovalsSnapshot(state, res); applyExecApprovalsSnapshot(state, res);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
@ -109,7 +117,9 @@ export async function saveExecApprovals(
state: ExecApprovalsState, state: ExecApprovalsState,
target?: ExecApprovalsTarget | null, target?: ExecApprovalsTarget | null,
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.execApprovalsSaving = true; state.execApprovalsSaving = true;
state.lastError = null; state.lastError = null;
try { try {

View file

@ -19,12 +19,18 @@ 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) { function parseMaybeJsonString(value: unknown) {
if (typeof value !== "string") {return null;} if (typeof value !== "string") {
return null;
}
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {return null;} if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try { try {
const parsed = JSON.parse(trimmed) as unknown; const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") {return null;} if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed as Record<string, unknown>; return parsed as Record<string, unknown>;
} catch { } catch {
return null; return null;
@ -32,13 +38,17 @@ function parseMaybeJsonString(value: unknown) {
} }
function normalizeLevel(value: unknown): LogLevel | null { function normalizeLevel(value: unknown): LogLevel | null {
if (typeof value !== "string") {return null;} if (typeof value !== "string") {
return null;
}
const lowered = value.toLowerCase() as LogLevel; const lowered = value.toLowerCase() as LogLevel;
return LEVELS.has(lowered) ? lowered : null; return LEVELS.has(lowered) ? lowered : null;
} }
export function parseLogLine(line: string): LogEntry { export function parseLogLine(line: string): LogEntry {
if (!line.trim()) {return { raw: line, message: line };} if (!line.trim()) {
return { raw: line, message: line };
}
try { try {
const obj = JSON.parse(line) as Record<string, unknown>; const obj = JSON.parse(line) as Record<string, unknown>;
const meta = const meta =
@ -50,25 +60,28 @@ export function parseLogLine(line: string): LogEntry {
const level = normalizeLevel(meta?.logLevelName ?? meta?.level); const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
const contextCandidate = const contextCandidate =
typeof obj["0"] === "string" typeof obj["0"] === "string" ? obj["0"] : typeof meta?.name === "string" ? meta?.name : null;
? (obj["0"])
: typeof meta?.name === "string"
? (meta?.name)
: null;
const contextObj = parseMaybeJsonString(contextCandidate); const contextObj = parseMaybeJsonString(contextCandidate);
let subsystem: string | null = null; let subsystem: string | null = null;
if (contextObj) { if (contextObj) {
if (typeof contextObj.subsystem === "string") {subsystem = contextObj.subsystem;} if (typeof contextObj.subsystem === "string") {
else if (typeof contextObj.module === "string") {subsystem = contextObj.module;} subsystem = contextObj.subsystem;
} else if (typeof contextObj.module === "string") {
subsystem = contextObj.module;
}
} }
if (!subsystem && contextCandidate && contextCandidate.length < 120) { if (!subsystem && contextCandidate && contextCandidate.length < 120) {
subsystem = contextCandidate; subsystem = contextCandidate;
} }
let message: string | null = null; let message: string | null = null;
if (typeof obj["1"] === "string") {message = obj["1"];} if (typeof obj["1"] === "string") {
else if (!contextObj && typeof obj["0"] === "string") {message = obj["0"];} message = obj["1"];
else if (typeof obj.message === "string") {message = obj.message;} } else if (!contextObj && typeof obj["0"] === "string") {
message = obj["0"];
} else if (typeof obj.message === "string") {
message = obj.message;
}
return { return {
raw: line, raw: line,
@ -84,9 +97,15 @@ 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.client || !state.connected) {
if (state.logsLoading && !opts?.quiet) {return;} return;
if (!opts?.quiet) {state.logsLoading = true;} }
if (state.logsLoading && !opts?.quiet) {
return;
}
if (!opts?.quiet) {
state.logsLoading = true;
}
state.logsError = null; state.logsError = null;
try { try {
const res = await state.client.request("logs.tail", { const res = await state.client.request("logs.tail", {
@ -103,20 +122,26 @@ export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet
reset?: boolean; reset?: boolean;
}; };
const lines = Array.isArray(payload.lines) const lines = Array.isArray(payload.lines)
? (payload.lines.filter((line) => typeof line === "string")) ? payload.lines.filter((line) => typeof line === "string")
: []; : [];
const entries = lines.map(parseLogLine); const entries = lines.map(parseLogLine);
const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null); const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null);
state.logsEntries = shouldReset state.logsEntries = shouldReset
? entries ? entries
: [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT); : [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
if (typeof payload.cursor === "number") {state.logsCursor = payload.cursor;} if (typeof payload.cursor === "number") {
if (typeof payload.file === "string") {state.logsFile = payload.file;} state.logsCursor = payload.cursor;
}
if (typeof payload.file === "string") {
state.logsFile = payload.file;
}
state.logsTruncated = Boolean(payload.truncated); state.logsTruncated = Boolean(payload.truncated);
state.logsLastFetchAt = Date.now(); state.logsLastFetchAt = Date.now();
} catch (err) { } catch (err) {
state.logsError = String(err); state.logsError = String(err);
} finally { } finally {
if (!opts?.quiet) {state.logsLoading = false;} if (!opts?.quiet) {
state.logsLoading = false;
}
} }
} }

View file

@ -9,15 +9,23 @@ export type NodesState = {
}; };
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.client || !state.connected) {
if (state.nodesLoading) {return;} return;
}
if (state.nodesLoading) {
return;
}
state.nodesLoading = true; state.nodesLoading = true;
if (!opts?.quiet) {state.lastError = null;} if (!opts?.quiet) {
state.lastError = null;
}
try { try {
const res = (await state.client.request("node.list", {})); const res = await state.client.request("node.list", {});
state.nodes = Array.isArray(res.nodes) ? res.nodes : []; state.nodes = Array.isArray(res.nodes) ? res.nodes : [];
} catch (err) { } catch (err) {
if (!opts?.quiet) {state.lastError = String(err);} if (!opts?.quiet) {
state.lastError = String(err);
}
} finally { } finally {
state.nodesLoading = false; state.nodesLoading = false;
} }

View file

@ -11,13 +11,17 @@ export type PresenceState = {
}; };
export async function loadPresence(state: PresenceState) { export async function loadPresence(state: PresenceState) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.presenceLoading) {return;} return;
}
if (state.presenceLoading) {
return;
}
state.presenceLoading = true; state.presenceLoading = true;
state.presenceError = null; state.presenceError = null;
state.presenceStatus = null; state.presenceStatus = null;
try { try {
const res = (await state.client.request("system-presence", {})); const res = await state.client.request("system-presence", {});
if (Array.isArray(res)) { if (Array.isArray(res)) {
state.presenceEntries = res; state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null; state.presenceStatus = res.length === 0 ? "No instances yet." : null;

View file

@ -23,8 +23,12 @@ export async function loadSessions(
includeUnknown?: boolean; includeUnknown?: boolean;
}, },
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.sessionsLoading) {return;} return;
}
if (state.sessionsLoading) {
return;
}
state.sessionsLoading = true; state.sessionsLoading = true;
state.sessionsError = null; state.sessionsError = null;
try { try {
@ -36,10 +40,16 @@ export async function loadSessions(
includeGlobal, includeGlobal,
includeUnknown, includeUnknown,
}; };
if (activeMinutes > 0) {params.activeMinutes = activeMinutes;} if (activeMinutes > 0) {
if (limit > 0) {params.limit = limit;} params.activeMinutes = activeMinutes;
const res = (await state.client.request("sessions.list", params)); }
if (res) {state.sessionsResult = res;} if (limit > 0) {
params.limit = limit;
}
const res = await state.client.request("sessions.list", params);
if (res) {
state.sessionsResult = res;
}
} catch (err) { } catch (err) {
state.sessionsError = String(err); state.sessionsError = String(err);
} finally { } finally {
@ -57,12 +67,22 @@ export async function patchSession(
reasoningLevel?: string | null; reasoningLevel?: string | null;
}, },
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
const params: Record<string, unknown> = { key }; const params: Record<string, unknown> = { key };
if ("label" in patch) {params.label = patch.label;} if ("label" in patch) {
if ("thinkingLevel" in patch) {params.thinkingLevel = patch.thinkingLevel;} params.label = patch.label;
if ("verboseLevel" in patch) {params.verboseLevel = patch.verboseLevel;} }
if ("reasoningLevel" in patch) {params.reasoningLevel = patch.reasoningLevel;} if ("thinkingLevel" in patch) {
params.thinkingLevel = patch.thinkingLevel;
}
if ("verboseLevel" in patch) {
params.verboseLevel = patch.verboseLevel;
}
if ("reasoningLevel" in patch) {
params.reasoningLevel = patch.reasoningLevel;
}
try { try {
await state.client.request("sessions.patch", params); await state.client.request("sessions.patch", params);
await loadSessions(state); await loadSessions(state);
@ -72,12 +92,18 @@ export async function patchSession(
} }
export async function deleteSession(state: SessionsState, key: string) { export async function deleteSession(state: SessionsState, key: string) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.sessionsLoading) {return;} return;
}
if (state.sessionsLoading) {
return;
}
const confirmed = window.confirm( const confirmed = window.confirm(
`Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`, `Delete session "${key}"?\n\nDeletes the session entry and archives its transcript.`,
); );
if (!confirmed) {return;} if (!confirmed) {
return;
}
state.sessionsLoading = true; state.sessionsLoading = true;
state.sessionsError = null; state.sessionsError = null;
try { try {

View file

@ -24,15 +24,22 @@ type LoadSkillsOptions = {
}; };
function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) { function setSkillMessage(state: SkillsState, key: string, message?: SkillMessage) {
if (!key.trim()) {return;} if (!key.trim()) {
return;
}
const next = { ...state.skillMessages }; const next = { ...state.skillMessages };
if (message) {next[key] = message;} if (message) {
else {delete next[key];} next[key] = message;
} else {
delete next[key];
}
state.skillMessages = next; state.skillMessages = next;
} }
function getErrorMessage(err: unknown) { function getErrorMessage(err: unknown) {
if (err instanceof Error) {return err.message;} if (err instanceof Error) {
return err.message;
}
return String(err); return String(err);
} }
@ -40,13 +47,19 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) { if (options?.clearMessages && Object.keys(state.skillMessages).length > 0) {
state.skillMessages = {}; state.skillMessages = {};
} }
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
if (state.skillsLoading) {return;} return;
}
if (state.skillsLoading) {
return;
}
state.skillsLoading = true; state.skillsLoading = true;
state.skillsError = null; state.skillsError = null;
try { try {
const res = (await state.client.request("skills.status", {})); const res = await state.client.request("skills.status", {});
if (res) {state.skillsReport = res;} if (res) {
state.skillsReport = res;
}
} catch (err) { } catch (err) {
state.skillsError = getErrorMessage(err); state.skillsError = getErrorMessage(err);
} finally { } finally {
@ -59,7 +72,9 @@ export function updateSkillEdit(state: SkillsState, skillKey: string, value: str
} }
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;} if (!state.client || !state.connected) {
return;
}
state.skillsBusyKey = skillKey; state.skillsBusyKey = skillKey;
state.skillsError = null; state.skillsError = null;
try { try {
@ -82,7 +97,9 @@ export async function updateSkillEnabled(state: SkillsState, skillKey: string, e
} }
export async function saveSkillApiKey(state: SkillsState, skillKey: string) { export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.skillsBusyKey = skillKey; state.skillsBusyKey = skillKey;
state.skillsError = null; state.skillsError = null;
try { try {
@ -111,15 +128,17 @@ export async function installSkill(
name: string, name: string,
installId: string, installId: string,
) { ) {
if (!state.client || !state.connected) {return;} if (!state.client || !state.connected) {
return;
}
state.skillsBusyKey = skillKey; state.skillsBusyKey = skillKey;
state.skillsError = null; state.skillsError = null;
try { try {
const result = (await state.client.request("skills.install", { const result = await state.client.request("skills.install", {
name, name,
installId, installId,
timeoutMs: 120000, timeoutMs: 120000,
})); });
await loadSkills(state); await loadSkills(state);
setSkillMessage(state, skillKey, { setSkillMessage(state, skillKey, {
kind: "success", kind: "success",

View file

@ -18,11 +18,15 @@ function normalizeRole(role: string): string {
} }
function normalizeScopes(scopes: string[] | undefined): string[] { function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) {return [];} if (!Array.isArray(scopes)) {
return [];
}
const out = new Set<string>(); const out = new Set<string>();
for (const scope of scopes) { for (const scope of scopes) {
const trimmed = scope.trim(); const trimmed = scope.trim();
if (trimmed) {out.add(trimmed);} if (trimmed) {
out.add(trimmed);
}
} }
return [...out].toSorted(); return [...out].toSorted();
} }
@ -30,11 +34,19 @@ function normalizeScopes(scopes: string[] | undefined): string[] {
function readStore(): DeviceAuthStore | null { function readStore(): DeviceAuthStore | null {
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {return null;} if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as DeviceAuthStore; const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) {return null;} if (!parsed || parsed.version !== 1) {
if (!parsed.deviceId || typeof parsed.deviceId !== "string") {return null;} return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") {return null;} }
if (!parsed.deviceId || typeof parsed.deviceId !== "string") {
return null;
}
if (!parsed.tokens || typeof parsed.tokens !== "object") {
return null;
}
return parsed; return parsed;
} catch { } catch {
return null; return null;
@ -54,10 +66,14 @@ export function loadDeviceAuthToken(params: {
role: string; role: string;
}): DeviceAuthEntry | null { }): DeviceAuthEntry | null {
const store = readStore(); const store = readStore();
if (!store || store.deviceId !== params.deviceId) {return null;} if (!store || store.deviceId !== params.deviceId) {
return null;
}
const role = normalizeRole(params.role); const role = normalizeRole(params.role);
const entry = store.tokens[role]; const entry = store.tokens[role];
if (!entry || typeof entry.token !== "string") {return null;} if (!entry || typeof entry.token !== "string") {
return null;
}
return entry; return entry;
} }
@ -90,9 +106,13 @@ export function storeDeviceAuthToken(params: {
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) { export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
const store = readStore(); const store = readStore();
if (!store || store.deviceId !== params.deviceId) {return;} if (!store || store.deviceId !== params.deviceId) {
return;
}
const role = normalizeRole(params.role); const role = normalizeRole(params.role);
if (!store.tokens[role]) {return;} if (!store.tokens[role]) {
return;
}
const next = { ...store, tokens: { ...store.tokens } }; const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[role]; delete next.tokens[role];
writeStore(next); writeStore(next);

View file

@ -18,7 +18,9 @@ const STORAGE_KEY = "openclaw-device-identity-v1";
function base64UrlEncode(bytes: Uint8Array): string { function base64UrlEncode(bytes: Uint8Array): string {
let binary = ""; let binary = "";
for (const byte of bytes) {binary += String.fromCharCode(byte);} for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
} }
@ -27,7 +29,9 @@ function base64UrlDecode(input: string): Uint8Array {
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded); const binary = atob(padded);
const out = new Uint8Array(binary.length); const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {out[i] = binary.charCodeAt(i);} for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out; return out;
} }

View file

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OpenClawApp } from "./app"; import { OpenClawApp } from "./app";
// oxlint-disable-next-line typescript/unbound-method
const originalConnect = OpenClawApp.prototype.connect; const originalConnect = OpenClawApp.prototype.connect;
function mountApp(pathname: string) { function mountApp(pathname: string) {

View file

@ -1,44 +1,70 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
export function formatMs(ms?: number | null): string { export function formatMs(ms?: number | null): string {
if (!ms && ms !== 0) {return "n/a";} if (!ms && ms !== 0) {
return "n/a";
}
return new Date(ms).toLocaleString(); return new Date(ms).toLocaleString();
} }
export function formatAgo(ms?: number | null): string { export function formatAgo(ms?: number | null): string {
if (!ms && ms !== 0) {return "n/a";} if (!ms && ms !== 0) {
return "n/a";
}
const diff = Date.now() - ms; const diff = Date.now() - ms;
if (diff < 0) {return "just now";} if (diff < 0) {
return "just now";
}
const sec = Math.round(diff / 1000); const sec = Math.round(diff / 1000);
if (sec < 60) {return `${sec}s ago`;} if (sec < 60) {
return `${sec}s ago`;
}
const min = Math.round(sec / 60); const min = Math.round(sec / 60);
if (min < 60) {return `${min}m ago`;} if (min < 60) {
return `${min}m ago`;
}
const hr = Math.round(min / 60); const hr = Math.round(min / 60);
if (hr < 48) {return `${hr}h ago`;} if (hr < 48) {
return `${hr}h ago`;
}
const day = Math.round(hr / 24); const day = Math.round(hr / 24);
return `${day}d ago`; return `${day}d ago`;
} }
export function formatDurationMs(ms?: number | null): string { export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) {return "n/a";} if (!ms && ms !== 0) {
if (ms < 1000) {return `${ms}ms`;} return "n/a";
}
if (ms < 1000) {
return `${ms}ms`;
}
const sec = Math.round(ms / 1000); const sec = Math.round(ms / 1000);
if (sec < 60) {return `${sec}s`;} if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60); const min = Math.round(sec / 60);
if (min < 60) {return `${min}m`;} if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60); const hr = Math.round(min / 60);
if (hr < 48) {return `${hr}h`;} if (hr < 48) {
return `${hr}h`;
}
const day = Math.round(hr / 24); const day = Math.round(hr / 24);
return `${day}d`; return `${day}d`;
} }
export function formatList(values?: Array<string | null | undefined>): string { export function formatList(values?: Array<string | null | undefined>): string {
if (!values || values.length === 0) {return "none";} if (!values || values.length === 0) {
return "none";
}
return values.filter((v): v is string => Boolean(v && v.trim())).join(", "); return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
} }
export function clampText(value: string, max = 120): string { export function clampText(value: string, max = 120): string {
if (value.length <= max) {return value;} if (value.length <= max) {
return value;
}
return `${value.slice(0, Math.max(0, max - 1))}`; return `${value.slice(0, Math.max(0, max - 1))}`;
} }

View file

@ -91,36 +91,44 @@ export class GatewayBrowserClient {
} }
private connect() { private connect() {
if (this.closed) {return;} if (this.closed) {
return;
}
this.ws = new WebSocket(this.opts.url); this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect(); this.ws.addEventListener("open", () => this.queueConnect());
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? "")); this.ws.addEventListener("message", (ev) => this.handleMessage(String(ev.data ?? "")));
this.ws.onclose = (ev) => { this.ws.addEventListener("close", (ev) => {
const reason = String(ev.reason ?? ""); const reason = String(ev.reason ?? "");
this.ws = null; this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason }); this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect(); this.scheduleReconnect();
}; });
this.ws.onerror = () => { this.ws.addEventListener("error", () => {
// ignored; close handler will fire // ignored; close handler will fire
}; });
} }
private scheduleReconnect() { private scheduleReconnect() {
if (this.closed) {return;} if (this.closed) {
return;
}
const delay = this.backoffMs; const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000); this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay); window.setTimeout(() => this.connect(), delay);
} }
private flushPending(err: Error) { private flushPending(err: Error) {
for (const [, p] of this.pending) {p.reject(err);} for (const [, p] of this.pending) {
p.reject(err);
}
this.pending.clear(); this.pending.clear();
} }
private async sendConnect() { private async sendConnect() {
if (this.connectSent) {return;} if (this.connectSent) {
return;
}
this.connectSent = true; this.connectSent = true;
if (this.connectTimer !== null) { if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer); window.clearTimeout(this.connectTimer);
@ -265,10 +273,15 @@ export class GatewayBrowserClient {
if (frame.type === "res") { if (frame.type === "res") {
const res = parsed as GatewayResponseFrame; const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id); const pending = this.pending.get(res.id);
if (!pending) {return;} if (!pending) {
return;
}
this.pending.delete(res.id); this.pending.delete(res.id);
if (res.ok) {pending.resolve(res.payload);} if (res.ok) {
else {pending.reject(new Error(res.error?.message ?? "request failed"));} pending.resolve(res.payload);
} else {
pending.reject(new Error(res.error?.message ?? "request failed"));
}
return; return;
} }
} }
@ -289,7 +302,9 @@ export class GatewayBrowserClient {
private queueConnect() { private queueConnect() {
this.connectNonce = null; this.connectNonce = null;
this.connectSent = false; this.connectSent = false;
if (this.connectTimer !== null) {window.clearTimeout(this.connectTimer);} if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
}
this.connectTimer = window.setTimeout(() => { this.connectTimer = window.setTimeout(() => {
void this.sendConnect(); void this.sendConnect();
}, 750); }, 750);

View file

@ -243,6 +243,8 @@ export function renderEmojiIcon(
} }
export function setEmojiIcon(target: HTMLElement | null, icon: string): void { export function setEmojiIcon(target: HTMLElement | null, icon: string): void {
if (!target) {return;} if (!target) {
return;
}
target.textContent = icon; target.textContent = icon;
} }

View file

@ -47,7 +47,9 @@ const markdownCache = new Map<string, string>();
function getCachedMarkdown(key: string): string | null { function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key); const cached = markdownCache.get(key);
if (cached === undefined) {return null;} if (cached === undefined) {
return null;
}
markdownCache.delete(key); markdownCache.delete(key);
markdownCache.set(key, cached); markdownCache.set(key, cached);
return cached; return cached;
@ -55,19 +57,29 @@ function getCachedMarkdown(key: string): string | null {
function setCachedMarkdown(key: string, value: string) { function setCachedMarkdown(key: string, value: string) {
markdownCache.set(key, value); markdownCache.set(key, value);
if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) {return;} if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) {
return;
}
const oldest = markdownCache.keys().next().value; const oldest = markdownCache.keys().next().value;
if (oldest) {markdownCache.delete(oldest);} if (oldest) {
markdownCache.delete(oldest);
}
} }
function installHooks() { function installHooks() {
if (hooksInstalled) {return;} if (hooksInstalled) {
return;
}
hooksInstalled = true; hooksInstalled = true;
DOMPurify.addHook("afterSanitizeAttributes", (node) => { DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (!(node instanceof HTMLAnchorElement)) {return;} if (!(node instanceof HTMLAnchorElement)) {
return;
}
const href = node.getAttribute("href"); const href = node.getAttribute("href");
if (!href) {return;} if (!href) {
return;
}
node.setAttribute("rel", "noreferrer noopener"); node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank"); node.setAttribute("target", "_blank");
}); });
@ -75,11 +87,15 @@ function installHooks() {
export function toSanitizedMarkdownHtml(markdown: string): string { export function toSanitizedMarkdownHtml(markdown: string): string {
const input = markdown.trim(); const input = markdown.trim();
if (!input) {return "";} if (!input) {
return "";
}
installHooks(); installHooks();
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
const cached = getCachedMarkdown(input); const cached = getCachedMarkdown(input);
if (cached !== null) {return cached;} if (cached !== null) {
return cached;
}
} }
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
const suffix = truncated.truncated const suffix = truncated.truncated

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OpenClawApp } from "./app"; import { OpenClawApp } from "./app";
import "../styles.css"; import "../styles.css";
// oxlint-disable-next-line typescript/unbound-method
const originalConnect = OpenClawApp.prototype.connect; const originalConnect = OpenClawApp.prototype.connect;
function mountApp(pathname: string) { function mountApp(pathname: string) {
@ -117,7 +118,9 @@ describe("control UI routing", () => {
const initialContainer = app.querySelector(".chat-thread"); const initialContainer = app.querySelector(".chat-thread");
expect(initialContainer).not.toBeNull(); expect(initialContainer).not.toBeNull();
if (!initialContainer) {return;} if (!initialContainer) {
return;
}
initialContainer.style.maxHeight = "180px"; initialContainer.style.maxHeight = "180px";
initialContainer.style.overflow = "auto"; initialContainer.style.overflow = "auto";
@ -134,11 +137,15 @@ describe("control UI routing", () => {
const container = app.querySelector(".chat-thread"); const container = app.querySelector(".chat-thread");
expect(container).not.toBeNull(); expect(container).not.toBeNull();
if (!container) {return;} if (!container) {
return;
}
const maxScroll = container.scrollHeight - container.clientHeight; const maxScroll = container.scrollHeight - container.clientHeight;
expect(maxScroll).toBeGreaterThan(0); expect(maxScroll).toBeGreaterThan(0);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
if (container.scrollTop === maxScroll) {break;} if (container.scrollTop === maxScroll) {
break;
}
await nextFrame(); await nextFrame();
} }
expect(container.scrollTop).toBe(maxScroll); expect(container.scrollTop).toBe(maxScroll);

View file

@ -40,18 +40,30 @@ const TAB_PATHS: Record<Tab, string> = {
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 { export function normalizeBasePath(basePath: string): string {
if (!basePath) {return "";} if (!basePath) {
return "";
}
let base = basePath.trim(); let base = basePath.trim();
if (!base.startsWith("/")) {base = `/${base}`;} if (!base.startsWith("/")) {
if (base === "/") {return "";} base = `/${base}`;
if (base.endsWith("/")) {base = base.slice(0, -1);} }
if (base === "/") {
return "";
}
if (base.endsWith("/")) {
base = base.slice(0, -1);
}
return base; return base;
} }
export function normalizePath(path: string): string { export function normalizePath(path: string): string {
if (!path) {return "/";} if (!path) {
return "/";
}
let normalized = path.trim(); let normalized = path.trim();
if (!normalized.startsWith("/")) {normalized = `/${normalized}`;} if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
if (normalized.length > 1 && normalized.endsWith("/")) { if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1); normalized = normalized.slice(0, -1);
} }
@ -75,8 +87,12 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null {
} }
} }
let normalized = normalizePath(path).toLowerCase(); let normalized = normalizePath(path).toLowerCase();
if (normalized.endsWith("/index.html")) {normalized = "/";} if (normalized.endsWith("/index.html")) {
if (normalized === "/") {return "chat";} normalized = "/";
}
if (normalized === "/") {
return "chat";
}
return PATH_TO_TAB.get(normalized) ?? null; return PATH_TO_TAB.get(normalized) ?? null;
} }
@ -85,9 +101,13 @@ export function inferBasePathFromPathname(pathname: string): string {
if (normalized.endsWith("/index.html")) { if (normalized.endsWith("/index.html")) {
normalized = normalizePath(normalized.slice(0, -"/index.html".length)); normalized = normalizePath(normalized.slice(0, -"/index.html".length));
} }
if (normalized === "/") {return "";} if (normalized === "/") {
return "";
}
const segments = normalized.split("/").filter(Boolean); const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0) {return "";} if (segments.length === 0) {
return "";
}
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase(); const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
if (PATH_TO_TAB.has(candidate)) { if (PATH_TO_TAB.has(candidate)) {

View file

@ -15,22 +15,29 @@ export function formatPresenceAge(entry: PresenceEntry): string {
} }
export function formatNextRun(ms?: number | null) { export function formatNextRun(ms?: number | null) {
if (!ms) {return "n/a";} if (!ms) {
return "n/a";
}
return `${formatMs(ms)} (${formatAgo(ms)})`; return `${formatMs(ms)} (${formatAgo(ms)})`;
} }
export function formatSessionTokens(row: GatewaySessionRow) { export function formatSessionTokens(row: GatewaySessionRow) {
if (row.totalTokens == null) {return "n/a";} if (row.totalTokens == null) {
return "n/a";
}
const total = row.totalTokens ?? 0; const total = row.totalTokens ?? 0;
const ctx = row.contextTokens ?? 0; const ctx = row.contextTokens ?? 0;
return ctx ? `${total} / ${ctx}` : String(total); return ctx ? `${total} / ${ctx}` : String(total);
} }
export function formatEventPayload(payload: unknown): string { export function formatEventPayload(payload: unknown): string {
if (payload == null) {return "";} if (payload == null) {
return "";
}
try { try {
return JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
} catch { } catch {
// oxlint-disable typescript/no-base-to-string
return String(payload); return String(payload);
} }
} }
@ -45,13 +52,19 @@ export function formatCronState(job: CronJob) {
export function formatCronSchedule(job: CronJob) { export function formatCronSchedule(job: CronJob) {
const s = job.schedule; const s = job.schedule;
if (s.kind === "at") {return `At ${formatMs(s.atMs)}`;} if (s.kind === "at") {
if (s.kind === "every") {return `Every ${formatDurationMs(s.everyMs)}`;} return `At ${formatMs(s.atMs)}`;
}
if (s.kind === "every") {
return `Every ${formatDurationMs(s.everyMs)}`;
}
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`; return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
} }
export function formatCronPayload(job: CronJob) { export function formatCronPayload(job: CronJob) {
const p = job.payload; const p = job.payload;
if (p.kind === "systemEvent") {return `System: ${p.text}`;} if (p.kind === "systemEvent") {
return `System: ${p.text}`;
}
return `Agent: ${p.message}`; return `Agent: ${p.message}`;
} }

View file

@ -36,7 +36,9 @@ export function loadSettings(): UiSettings {
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
if (!raw) {return defaults;} if (!raw) {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>; const parsed = JSON.parse(raw) as Partial<UiSettings>;
return { return {
gatewayUrl: gatewayUrl:

View file

@ -18,9 +18,15 @@ type DocumentWithViewTransition = Document & {
}; };
const clamp01 = (value: number) => { const clamp01 = (value: number) => {
if (Number.isNaN(value)) {return 0.5;} if (Number.isNaN(value)) {
if (value <= 0) {return 0;} return 0.5;
if (value >= 1) {return 1;} }
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value; return value;
}; };
@ -43,7 +49,9 @@ export const startThemeTransition = ({
context, context,
currentTheme, currentTheme,
}: ThemeTransitionOptions) => { }: ThemeTransitionOptions) => {
if (currentTheme === nextTheme) {return;} if (currentTheme === nextTheme) {
return;
}
const documentReference = globalThis.document ?? null; const documentReference = globalThis.document ?? null;
if (!documentReference) { if (!documentReference) {

View file

@ -9,6 +9,8 @@ export function getSystemTheme(): ResolvedTheme {
} }
export function resolveTheme(mode: ThemeMode): ResolvedTheme { export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") {return getSystemTheme();} if (mode === "system") {
return getSystemTheme();
}
return mode; return mode;
} }

View file

@ -39,7 +39,9 @@ function normalizeToolName(name?: string): string {
function defaultTitle(name: string): string { function defaultTitle(name: string): string {
const cleaned = name.replace(/_/g, " ").trim(); const cleaned = name.replace(/_/g, " ").trim();
if (!cleaned) {return "Tool";} if (!cleaned) {
return "Tool";
}
return cleaned return cleaned
.split(/\s+/) .split(/\s+/)
.map((part) => .map((part) =>
@ -52,17 +54,25 @@ function defaultTitle(name: string): string {
function normalizeVerb(value?: string): string | undefined { function normalizeVerb(value?: string): string | undefined {
const trimmed = value?.trim(); const trimmed = value?.trim();
if (!trimmed) {return undefined;} if (!trimmed) {
return undefined;
}
return trimmed.replace(/_/g, " "); return trimmed.replace(/_/g, " ");
} }
function coerceDisplayValue(value: unknown): string | undefined { function coerceDisplayValue(value: unknown): string | undefined {
if (value === null || value === undefined) {return undefined;} if (value === null || value === undefined) {
return undefined;
}
if (typeof value === "string") { if (typeof value === "string") {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) {return undefined;} if (!trimmed) {
return undefined;
}
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? ""; const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
if (!firstLine) {return undefined;} if (!firstLine) {
return undefined;
}
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine; return firstLine.length > 160 ? `${firstLine.slice(0, 157)}` : firstLine;
} }
if (typeof value === "number" || typeof value === "boolean") { if (typeof value === "number" || typeof value === "boolean") {
@ -72,7 +82,9 @@ function coerceDisplayValue(value: unknown): string | undefined {
const values = value const values = value
.map((item) => coerceDisplayValue(item)) .map((item) => coerceDisplayValue(item))
.filter((item): item is string => Boolean(item)); .filter((item): item is string => Boolean(item));
if (values.length === 0) {return undefined;} if (values.length === 0) {
return undefined;
}
const preview = values.slice(0, 3).join(", "); const preview = values.slice(0, 3).join(", ");
return values.length > 3 ? `${preview}` : preview; return values.length > 3 ? `${preview}` : preview;
} }
@ -80,11 +92,17 @@ function coerceDisplayValue(value: unknown): string | undefined {
} }
function lookupValueByPath(args: unknown, path: string): unknown { function lookupValueByPath(args: unknown, path: string): unknown {
if (!args || typeof args !== "object") {return undefined;} if (!args || typeof args !== "object") {
return undefined;
}
let current: unknown = args; let current: unknown = args;
for (const segment of path.split(".")) { for (const segment of path.split(".")) {
if (!segment) {return undefined;} if (!segment) {
if (!current || typeof current !== "object") {return undefined;} return undefined;
}
if (!current || typeof current !== "object") {
return undefined;
}
const record = current as Record<string, unknown>; const record = current as Record<string, unknown>;
current = record[segment]; current = record[segment];
} }
@ -95,16 +113,22 @@ function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefine
for (const key of keys) { for (const key of keys) {
const value = lookupValueByPath(args, key); const value = lookupValueByPath(args, key);
const display = coerceDisplayValue(value); const display = coerceDisplayValue(value);
if (display) {return display;} if (display) {
return display;
}
} }
return undefined; return undefined;
} }
function resolveReadDetail(args: unknown): string | undefined { function resolveReadDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") {return undefined;} if (!args || typeof args !== "object") {
return undefined;
}
const record = args as Record<string, unknown>; const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined; const path = typeof record.path === "string" ? record.path : undefined;
if (!path) {return undefined;} if (!path) {
return undefined;
}
const offset = typeof record.offset === "number" ? record.offset : undefined; const offset = typeof record.offset === "number" ? record.offset : undefined;
const limit = typeof record.limit === "number" ? record.limit : undefined; const limit = typeof record.limit === "number" ? record.limit : undefined;
if (offset !== undefined && limit !== undefined) { if (offset !== undefined && limit !== undefined) {
@ -114,7 +138,9 @@ function resolveReadDetail(args: unknown): string | undefined {
} }
function resolveWriteDetail(args: unknown): string | undefined { function resolveWriteDetail(args: unknown): string | undefined {
if (!args || typeof args !== "object") {return undefined;} if (!args || typeof args !== "object") {
return undefined;
}
const record = args as Record<string, unknown>; const record = args as Record<string, unknown>;
const path = typeof record.path === "string" ? record.path : undefined; const path = typeof record.path === "string" ? record.path : undefined;
return path; return path;
@ -124,7 +150,9 @@ function resolveActionSpec(
spec: ToolDisplaySpec | undefined, spec: ToolDisplaySpec | undefined,
action: string | undefined, action: string | undefined,
): ToolDisplayActionSpec | undefined { ): ToolDisplayActionSpec | undefined {
if (!spec || !action) {return undefined;} if (!spec || !action) {
return undefined;
}
return spec.actions?.[action] ?? undefined; return spec.actions?.[action] ?? undefined;
} }
@ -148,7 +176,9 @@ export function resolveToolDisplay(params: {
const verb = normalizeVerb(actionSpec?.label ?? action); const verb = normalizeVerb(actionSpec?.label ?? action);
let detail: string | undefined; let detail: string | undefined;
if (key === "read") {detail = resolveReadDetail(params.args);} if (key === "read") {
detail = resolveReadDetail(params.args);
}
if (!detail && (key === "write" || key === "edit" || key === "attach")) { if (!detail && (key === "write" || key === "edit" || key === "attach")) {
detail = resolveWriteDetail(params.args); detail = resolveWriteDetail(params.args);
} }
@ -178,9 +208,15 @@ export function resolveToolDisplay(params: {
export function formatToolDetail(display: ToolDisplay): string | undefined { export function formatToolDetail(display: ToolDisplay): string | undefined {
const parts: string[] = []; const parts: string[] = [];
if (display.verb) {parts.push(display.verb);} if (display.verb) {
if (display.detail) {parts.push(display.detail);} parts.push(display.verb);
if (parts.length === 0) {return undefined;} }
if (display.detail) {
parts.push(display.detail);
}
if (parts.length === 0) {
return undefined;
}
return parts.join(" · "); return parts.join(" · ");
} }
@ -190,6 +226,8 @@ export function formatToolSummary(display: ToolDisplay): string {
} }
function shortenHomeInString(input: string): string { function shortenHomeInString(input: string): string {
if (!input) {return input;} if (!input) {
return input;
}
return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~"); return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~");
} }

View file

@ -16,7 +16,9 @@ describe("generateUUID", () => {
it("falls back to crypto.getRandomValues", () => { it("falls back to crypto.getRandomValues", () => {
const id = generateUUID({ const id = generateUUID({
getRandomValues: (bytes) => { getRandomValues: (bytes) => {
for (let i = 0; i < bytes.length; i++) {bytes[i] = i;} for (let i = 0; i < bytes.length; i++) {
bytes[i] = i;
}
return bytes; return bytes;
}, },
}); });

View file

@ -23,7 +23,9 @@ function uuidFromBytes(bytes: Uint8Array): string {
function weakRandomBytes(): Uint8Array { function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16);
const now = Date.now(); const now = Date.now();
for (let i = 0; i < bytes.length; i++) {bytes[i] = Math.floor(Math.random() * 256);} for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
bytes[0] ^= now & 0xff; bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff; bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff; bytes[2] ^= (now >>> 16) & 0xff;
@ -32,13 +34,17 @@ function weakRandomBytes(): Uint8Array {
} }
function warnWeakCryptoOnce() { function warnWeakCryptoOnce() {
if (warnedWeakCrypto) {return;} if (warnedWeakCrypto) {
return;
}
warnedWeakCrypto = true; warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness"); console.warn("[uuid] crypto API missing; falling back to weak randomness");
} }
export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string { export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") {return cryptoLike.randomUUID();} if (cryptoLike && typeof cryptoLike.randomUUID === "function") {
return cryptoLike.randomUUID();
}
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") { if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16);

View file

@ -18,7 +18,9 @@ function resolveSchemaNode(
): JsonSchema | null { ): JsonSchema | null {
let current = schema; let current = schema;
for (const key of path) { for (const key of path) {
if (!current) {return null;} if (!current) {
return null;
}
const type = schemaType(current); const type = schemaType(current);
if (type === "object") { if (type === "object") {
const properties = current.properties ?? {}; const properties = current.properties ?? {};
@ -34,7 +36,9 @@ function resolveSchemaNode(
return null; return null;
} }
if (type === "array") { if (type === "array") {
if (typeof key !== "number") {return null;} if (typeof key !== "number") {
return null;
}
const items = Array.isArray(current.items) ? current.items[0] : current.items; const items = Array.isArray(current.items) ? current.items[0] : current.items;
current = items ?? null; current = items ?? null;
continue; continue;

View file

@ -140,7 +140,9 @@ export function renderNostrProfileForm(params: {
const renderPicturePreview = () => { const renderPicturePreview = () => {
const picture = state.values.picture; const picture = state.values.picture;
if (!picture) {return nothing;} if (!picture) {
return nothing;
}
return html` return html`
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">

View file

@ -13,8 +13,12 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars) * Truncate a pubkey for display (shows first and last 8 chars)
*/ */
function truncatePubkey(pubkey: string | null | undefined): string { function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) {return "n/a";} if (!pubkey) {
if (pubkey.length <= 20) {return pubkey;} return "n/a";
}
if (pubkey.length <= 20) {
return pubkey;
}
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`; return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
} }

View file

@ -3,11 +3,17 @@ import type { ChannelAccountSnapshot } from "../types";
import type { ChannelKey, ChannelsProps } from "./channels.types"; import type { ChannelKey, ChannelsProps } from "./channels.types";
export function formatDuration(ms?: number | null) { export function formatDuration(ms?: number | null) {
if (!ms && ms !== 0) {return "n/a";} if (!ms && ms !== 0) {
return "n/a";
}
const sec = Math.round(ms / 1000); const sec = Math.round(ms / 1000);
if (sec < 60) {return `${sec}s`;} if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60); const min = Math.round(sec / 60);
if (min < 60) {return `${min}m`;} if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60); const hr = Math.round(min / 60);
return `${hr}h`; return `${hr}h`;
} }
@ -15,7 +21,9 @@ export function formatDuration(ms?: number | null) {
export function channelEnabled(key: ChannelKey, props: ChannelsProps) { export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot; const snapshot = props.snapshot;
const channels = snapshot?.channels as Record<string, unknown> | null; const channels = snapshot?.channels as Record<string, unknown> | null;
if (!snapshot || !channels) {return false;} if (!snapshot || !channels) {
return false;
}
const channelStatus = channels[key] as Record<string, unknown> | undefined; const channelStatus = channels[key] as Record<string, unknown> | undefined;
const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured; const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
const running = typeof channelStatus?.running === "boolean" && channelStatus.running; const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
@ -39,6 +47,8 @@ export function renderChannelAccountCount(
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null, channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) { ) {
const count = getChannelAccountCount(key, channelAccounts); const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) {return nothing;} if (count < 2) {
return nothing;
}
return html`<div class="account-count">Accounts (${count})</div>`; return html`<div class="account-count">Accounts (${count})</div>`;
} }

View file

@ -44,7 +44,9 @@ export function renderChannels(props: ChannelsProps) {
order: index, order: index,
})) }))
.toSorted((a, b) => { .toSorted((a, b) => {
if (a.enabled !== b.enabled) {return a.enabled ? -1 : 1;} if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1;
}
return a.order - b.order; return a.order - b.order;
}); });
@ -236,7 +238,9 @@ function renderGenericChannelCard(
function resolveChannelMetaMap( function resolveChannelMetaMap(
snapshot: ChannelsStatusSnapshot | null, snapshot: ChannelsStatusSnapshot | null,
): Record<string, ChannelUiMetaEntry> { ): Record<string, ChannelUiMetaEntry> {
if (!snapshot?.channelMeta?.length) {return {};} if (!snapshot?.channelMeta?.length) {
return {};
}
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
} }
@ -248,22 +252,34 @@ function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: strin
const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes const RECENT_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
function hasRecentActivity(account: ChannelAccountSnapshot): boolean { function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
if (!account.lastInboundAt) {return false;} if (!account.lastInboundAt) {
return false;
}
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS; return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
} }
function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" { function deriveRunningStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" {
if (account.running) {return "Yes";} if (account.running) {
return "Yes";
}
// If we have recent inbound activity, the channel is effectively running // If we have recent inbound activity, the channel is effectively running
if (hasRecentActivity(account)) {return "Active";} if (hasRecentActivity(account)) {
return "Active";
}
return "No"; return "No";
} }
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" { function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
if (account.connected === true) {return "Yes";} if (account.connected === true) {
if (account.connected === false) {return "No";} return "Yes";
}
if (account.connected === false) {
return "No";
}
// If connected is null/undefined but we have recent activity, show as active // If connected is null/undefined but we have recent activity, show as active
if (hasRecentActivity(account)) {return "Active";} if (hasRecentActivity(account)) {
return "Active";
}
return "n/a"; return "n/a";
} }

View file

@ -75,7 +75,9 @@ function adjustTextareaHeight(el: HTMLTextAreaElement) {
} }
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) { function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
if (!status) {return nothing;} if (!status) {
return nothing;
}
// Show "compacting..." while active // Show "compacting..." while active
if (status.active) { if (status.active) {
@ -107,7 +109,9 @@ function generateAttachmentId(): string {
function handlePaste(e: ClipboardEvent, props: ChatProps) { function handlePaste(e: ClipboardEvent, props: ChatProps) {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) {return;} if (!items || !props.onAttachmentsChange) {
return;
}
const imageItems: DataTransferItem[] = []; const imageItems: DataTransferItem[] = [];
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
@ -117,16 +121,20 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) {
} }
} }
if (imageItems.length === 0) {return;} if (imageItems.length === 0) {
return;
}
e.preventDefault(); e.preventDefault();
for (const item of imageItems) { for (const item of imageItems) {
const file = item.getAsFile(); const file = item.getAsFile();
if (!file) {continue;} if (!file) {
continue;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.addEventListener("load", () => {
const dataUrl = reader.result as string; const dataUrl = reader.result as string;
const newAttachment: ChatAttachment = { const newAttachment: ChatAttachment = {
id: generateAttachmentId(), id: generateAttachmentId(),
@ -135,14 +143,16 @@ function handlePaste(e: ClipboardEvent, props: ChatProps) {
}; };
const current = props.attachments ?? []; const current = props.attachments ?? [];
props.onAttachmentsChange?.([...current, newAttachment]); props.onAttachmentsChange?.([...current, newAttachment]);
}; });
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
} }
function renderAttachmentPreview(props: ChatProps) { function renderAttachmentPreview(props: ChatProps) {
const attachments = props.attachments ?? []; const attachments = props.attachments ?? [];
if (attachments.length === 0) {return nothing;} if (attachments.length === 0) {
return nothing;
}
return html` return html`
<div class="chat-attachments"> <div class="chat-attachments">
@ -286,7 +296,9 @@ export function renderChat(props: ChatProps) {
error: props.sidebarError ?? null, error: props.sidebarError ?? null,
onClose: props.onCloseSidebar!, onClose: props.onCloseSidebar!,
onViewRawText: () => { onViewRawText: () => {
if (!props.sidebarContent || !props.onOpenSidebar) {return;} if (!props.sidebarContent || !props.onOpenSidebar) {
return;
}
props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``); props.onOpenSidebar(`\`\`\`\n${props.sidebarContent}\n\`\`\``);
}, },
})} })}
@ -338,12 +350,22 @@ export function renderChat(props: ChatProps) {
.value=${props.draft} .value=${props.draft}
?disabled=${!props.connected} ?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") {return;} if (e.key !== "Enter") {
if (e.isComposing || e.keyCode === 229) {return;} return;
if (e.shiftKey) {return;} // Allow Shift+Enter for line breaks }
if (!props.connected) {return;} if (e.isComposing || e.keyCode === 229) {
return;
}
if (e.shiftKey) {
return;
} // Allow Shift+Enter for line breaks
if (!props.connected) {
return;
}
e.preventDefault(); e.preventDefault();
if (canCompose) {props.onSend();} if (canCompose) {
props.onSend();
}
}} }}
@input=${(e: Event) => { @input=${(e: Event) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
@ -397,7 +419,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const timestamp = normalized.timestamp || Date.now(); const timestamp = normalized.timestamp || Date.now();
if (!currentGroup || currentGroup.role !== role) { if (!currentGroup || currentGroup.role !== role) {
if (currentGroup) {result.push(currentGroup);} if (currentGroup) {
result.push(currentGroup);
}
currentGroup = { currentGroup = {
kind: "group", kind: "group",
key: `group:${role}:${item.key}`, key: `group:${role}:${item.key}`,
@ -411,7 +435,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
} }
} }
if (currentGroup) {result.push(currentGroup);} if (currentGroup) {
result.push(currentGroup);
}
return result; return result;
} }
@ -475,13 +501,21 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
function messageKey(message: unknown, index: number): string { function messageKey(message: unknown, index: number): string {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
if (toolCallId) {return `tool:${toolCallId}`;} if (toolCallId) {
return `tool:${toolCallId}`;
}
const id = typeof m.id === "string" ? m.id : ""; const id = typeof m.id === "string" ? m.id : "";
if (id) {return `msg:${id}`;} if (id) {
return `msg:${id}`;
}
const messageId = typeof m.messageId === "string" ? m.messageId : ""; const messageId = typeof m.messageId === "string" ? m.messageId : "";
if (messageId) {return `msg:${messageId}`;} if (messageId) {
return `msg:${messageId}`;
}
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null; const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
if (timestamp != null) {return `msg:${role}:${timestamp}:${index}`;} if (timestamp != null) {
return `msg:${role}:${timestamp}:${index}`;
}
return `msg:${role}:${index}`; return `msg:${role}:${index}`;
} }

View file

@ -41,7 +41,9 @@ function normalizeSchemaNode(
if (schema.anyOf || schema.oneOf || schema.allOf) { if (schema.anyOf || schema.oneOf || schema.allOf) {
const union = normalizeUnion(schema, path); const union = normalizeUnion(schema, path);
if (union) {return union;} if (union) {
return union;
}
return { schema, unsupportedPaths: [pathLabel] }; return { schema, unsupportedPaths: [pathLabel] };
} }
@ -54,8 +56,12 @@ function normalizeSchemaNode(
if (normalized.enum) { if (normalized.enum) {
const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum); const { enumValues, nullable: enumNullable } = normalizeEnum(normalized.enum);
normalized.enum = enumValues; normalized.enum = enumValues;
if (enumNullable) {normalized.nullable = true;} if (enumNullable) {
if (enumValues.length === 0) {unsupported.add(pathLabel);} normalized.nullable = true;
}
if (enumValues.length === 0) {
unsupported.add(pathLabel);
}
} }
if (type === "object") { if (type === "object") {
@ -63,8 +69,12 @@ function normalizeSchemaNode(
const normalizedProps: Record<string, JsonSchema> = {}; const normalizedProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(properties)) { for (const [key, value] of Object.entries(properties)) {
const res = normalizeSchemaNode(value, [...path, key]); const res = normalizeSchemaNode(value, [...path, key]);
if (res.schema) {normalizedProps[key] = res.schema;} if (res.schema) {
for (const entry of res.unsupportedPaths) {unsupported.add(entry);} normalizedProps[key] = res.schema;
}
for (const entry of res.unsupportedPaths) {
unsupported.add(entry);
}
} }
normalized.properties = normalizedProps; normalized.properties = normalizedProps;
@ -75,8 +85,10 @@ function normalizeSchemaNode(
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (!isAnySchema(schema.additionalProperties)) { if (!isAnySchema(schema.additionalProperties)) {
const res = normalizeSchemaNode(schema.additionalProperties, [...path, "*"]); const res = normalizeSchemaNode(schema.additionalProperties, [...path, "*"]);
normalized.additionalProperties = res.schema ?? (schema.additionalProperties); normalized.additionalProperties = res.schema ?? schema.additionalProperties;
if (res.unsupportedPaths.length > 0) {unsupported.add(pathLabel);} if (res.unsupportedPaths.length > 0) {
unsupported.add(pathLabel);
}
} }
} }
} else if (type === "array") { } else if (type === "array") {
@ -86,7 +98,9 @@ function normalizeSchemaNode(
} else { } else {
const res = normalizeSchemaNode(itemsSchema, [...path, "*"]); const res = normalizeSchemaNode(itemsSchema, [...path, "*"]);
normalized.items = res.schema ?? itemsSchema; normalized.items = res.schema ?? itemsSchema;
if (res.unsupportedPaths.length > 0) {unsupported.add(pathLabel);} if (res.unsupportedPaths.length > 0) {
unsupported.add(pathLabel);
}
} }
} else if ( } else if (
type !== "string" && type !== "string" &&
@ -108,20 +122,28 @@ function normalizeUnion(
schema: JsonSchema, schema: JsonSchema,
path: Array<string | number>, path: Array<string | number>,
): ConfigSchemaAnalysis | null { ): ConfigSchemaAnalysis | null {
if (schema.allOf) {return null;} if (schema.allOf) {
return null;
}
const union = schema.anyOf ?? schema.oneOf; const union = schema.anyOf ?? schema.oneOf;
if (!union) {return null;} if (!union) {
return null;
}
const literals: unknown[] = []; const literals: unknown[] = [];
const remaining: JsonSchema[] = []; const remaining: JsonSchema[] = [];
let nullable = false; let nullable = false;
for (const entry of union) { for (const entry of union) {
if (!entry || typeof entry !== "object") {return null;} if (!entry || typeof entry !== "object") {
return null;
}
if (Array.isArray(entry.enum)) { if (Array.isArray(entry.enum)) {
const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum); const { enumValues, nullable: enumNullable } = normalizeEnum(entry.enum);
literals.push(...enumValues); literals.push(...enumValues);
if (enumNullable) {nullable = true;} if (enumNullable) {
nullable = true;
}
continue; continue;
} }
if ("const" in entry) { if ("const" in entry) {

View file

@ -18,7 +18,9 @@ function isAnySchema(schema: JsonSchema): boolean {
} }
function jsonValue(value: unknown): string { function jsonValue(value: unknown): string {
if (value === undefined) {return "";} if (value === undefined) {
return "";
}
try { try {
return JSON.stringify(value, null, 2) ?? ""; return JSON.stringify(value, null, 2) ?? "";
} catch { } catch {
@ -131,8 +133,12 @@ export function renderNode(params: {
// Check if it's a set of literal values (enum-like) // Check if it's a set of literal values (enum-like)
const extractLiteral = (v: JsonSchema): unknown | undefined => { const extractLiteral = (v: JsonSchema): unknown | undefined => {
if (v.const !== undefined) {return v.const;} if (v.const !== undefined) {
if (v.enum && v.enum.length === 1) {return v.enum[0];} return v.const;
}
if (v.enum && v.enum.length === 1) {
return v.enum[0];
}
return undefined; return undefined;
}; };
const literals = nonNull.map(extractLiteral); const literals = nonNull.map(extractLiteral);
@ -147,14 +153,20 @@ export function renderNode(params: {
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented"> <div class="cfg-segmented">
${literals.map( ${literals.map(
(lit, idx) => html` (lit) => html`
<button <button
type="button" type="button"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""}" class="cfg-segmented__btn ${
// oxlint-disable typescript/no-base-to-string
lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""
}"
?disabled=${disabled} ?disabled=${disabled}
@click=${() => onPatch(path, lit)} @click=${() => onPatch(path, lit)}
> >
${String(lit)} ${
// oxlint-disable typescript/no-base-to-string
String(lit)
}
</button> </button>
`, `,
)} )}
@ -298,7 +310,12 @@ function renderTextInput(params: {
const isSensitive = hint?.sensitive ?? isSensitivePath(path); const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = const placeholder =
hint?.placeholder ?? hint?.placeholder ??
(isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : ""); // oxlint-disable typescript/no-base-to-string
(isSensitive
? "••••"
: schema.default !== undefined
? `Default: ${String(schema.default)}`
: "");
const displayValue = value ?? ""; const displayValue = value ?? "";
return html` return html`
@ -326,7 +343,9 @@ function renderTextInput(params: {
onPatch(path, raw); onPatch(path, raw);
}} }}
@change=${(e: Event) => { @change=${(e: Event) => {
if (inputType === "number") {return;} if (inputType === "number") {
return;
}
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim()); onPatch(path, raw.trim());
}} }}
@ -455,7 +474,6 @@ function renderObject(params: {
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params; const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints); const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description; const help = hint?.help ?? schema.description;
@ -472,7 +490,9 @@ function renderObject(params: {
const sorted = entries.toSorted((a, b) => { const sorted = entries.toSorted((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0; const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0; const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) {return orderA - orderB;} if (orderA !== orderB) {
return orderA - orderB;
}
return a[0].localeCompare(b[0]); return a[0].localeCompare(b[0]);
}); });
@ -708,9 +728,13 @@ function renderMapField(params: {
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => { @change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim(); const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {return;} if (!nextKey || nextKey === key) {
return;
}
const next = { ...value }; const next = { ...value };
if (nextKey in next) {return;} if (nextKey in next) {
return;
}
next[nextKey] = next[key]; next[nextKey] = next[key];
delete next[key]; delete next[key];
onPatch(path, next); onPatch(path, next);

View file

@ -279,49 +279,73 @@ function getSectionIcon(key: string) {
} }
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean { function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) {return true;} if (!query) {
return true;
}
const q = query.toLowerCase(); const q = query.toLowerCase();
const meta = SECTION_META[key]; const meta = SECTION_META[key];
// Check key name // Check key name
if (key.toLowerCase().includes(q)) {return true;} if (key.toLowerCase().includes(q)) {
return true;
}
// Check label and description // Check label and description
if (meta) { if (meta) {
if (meta.label.toLowerCase().includes(q)) {return true;} if (meta.label.toLowerCase().includes(q)) {
if (meta.description.toLowerCase().includes(q)) {return true;} return true;
}
if (meta.description.toLowerCase().includes(q)) {
return true;
}
} }
return schemaMatches(schema, q); return schemaMatches(schema, q);
} }
function schemaMatches(schema: JsonSchema, query: string): boolean { function schemaMatches(schema: JsonSchema, query: string): boolean {
if (schema.title?.toLowerCase().includes(query)) {return true;} if (schema.title?.toLowerCase().includes(query)) {
if (schema.description?.toLowerCase().includes(query)) {return true;} return true;
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {return true;} }
if (schema.description?.toLowerCase().includes(query)) {
return true;
}
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {
return true;
}
if (schema.properties) { if (schema.properties) {
for (const [propKey, propSchema] of Object.entries(schema.properties)) { for (const [propKey, propSchema] of Object.entries(schema.properties)) {
if (propKey.toLowerCase().includes(query)) {return true;} if (propKey.toLowerCase().includes(query)) {
if (schemaMatches(propSchema, query)) {return true;} return true;
}
if (schemaMatches(propSchema, query)) {
return true;
}
} }
} }
if (schema.items) { if (schema.items) {
const items = Array.isArray(schema.items) ? schema.items : [schema.items]; const items = Array.isArray(schema.items) ? schema.items : [schema.items];
for (const item of items) { for (const item of items) {
if (item && schemaMatches(item, query)) {return true;} if (item && schemaMatches(item, query)) {
return true;
}
} }
} }
if (schema.additionalProperties && typeof schema.additionalProperties === "object") { if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (schemaMatches(schema.additionalProperties, query)) {return true;} if (schemaMatches(schema.additionalProperties, query)) {
return true;
}
} }
const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf; const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (unions) { if (unions) {
for (const entry of unions) { for (const entry of unions) {
if (entry && schemaMatches(entry, query)) {return true;} if (entry && schemaMatches(entry, query)) {
return true;
}
} }
} }
@ -350,13 +374,19 @@ export function renderConfigForm(props: ConfigFormProps) {
const entries = Object.entries(properties).toSorted((a, b) => { const entries = Object.entries(properties).toSorted((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50; const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 50;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50; const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 50;
if (orderA !== orderB) {return orderA - orderB;} if (orderA !== orderB) {
return orderA - orderB;
}
return a[0].localeCompare(b[0]); return a[0].localeCompare(b[0]);
}); });
const filteredEntries = entries.filter(([key, node]) => { const filteredEntries = entries.filter(([key, node]) => {
if (activeSection && key !== activeSection) {return false;} if (activeSection && key !== activeSection) {
if (searchQuery && !matchesSearch(key, node, searchQuery)) {return false;} return false;
}
if (searchQuery && !matchesSearch(key, node, searchQuery)) {
return false;
}
return true; return true;
}); });
@ -398,7 +428,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints); const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey); const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? ""; const description = hint?.help ?? node.description ?? "";
const sectionValue = (value)[sectionKey]; const sectionValue = value[sectionKey];
const scopedValue = const scopedValue =
sectionValue && typeof sectionValue === "object" sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey] ? (sectionValue as Record<string, unknown>)[subsectionKey]
@ -454,7 +484,7 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-section-card__content"> <div class="config-section-card__content">
${renderNode({ ${renderNode({
schema: node, schema: node,
value: (value)[key], value: value[key],
path: [key], path: [key],
hints: props.uiHints, hints: props.uiHints,
unsupported, unsupported,

View file

@ -17,7 +17,9 @@ export type JsonSchema = {
}; };
export function schemaType(schema: JsonSchema): string | undefined { export function schemaType(schema: JsonSchema): string | undefined {
if (!schema) {return undefined;} if (!schema) {
return undefined;
}
if (Array.isArray(schema.type)) { if (Array.isArray(schema.type)) {
const filtered = schema.type.filter((t) => t !== "null"); const filtered = schema.type.filter((t) => t !== "null");
return filtered[0] ?? schema.type[0]; return filtered[0] ?? schema.type[0];
@ -26,8 +28,12 @@ export function schemaType(schema: JsonSchema): string | undefined {
} }
export function defaultValue(schema?: JsonSchema): unknown { export function defaultValue(schema?: JsonSchema): unknown {
if (!schema) {return "";} if (!schema) {
if (schema.default !== undefined) {return schema.default;} return "";
}
if (schema.default !== undefined) {
return schema.default;
}
const type = schemaType(schema); const type = schemaType(schema);
switch (type) { switch (type) {
case "object": case "object":
@ -53,12 +59,18 @@ export function pathKey(path: Array<string | number>): string {
export function hintForPath(path: Array<string | number>, hints: ConfigUiHints) { export function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
const key = pathKey(path); const key = pathKey(path);
const direct = hints[key]; const direct = hints[key];
if (direct) {return direct;} if (direct) {
return direct;
}
const segments = key.split("."); const segments = key.split(".");
for (const [hintKey, hint] of Object.entries(hints)) { for (const [hintKey, hint] of Object.entries(hints)) {
if (!hintKey.includes("*")) {continue;} if (!hintKey.includes("*")) {
continue;
}
const hintSegments = hintKey.split("."); const hintSegments = hintKey.split(".");
if (hintSegments.length !== segments.length) {continue;} if (hintSegments.length !== segments.length) {
continue;
}
let match = true; let match = true;
for (let i = 0; i < segments.length; i += 1) { for (let i = 0; i < segments.length; i += 1) {
if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) {
@ -66,7 +78,9 @@ export function hintForPath(path: Array<string | number>, hints: ConfigUiHints)
break; break;
} }
} }
if (match) {return hint;} if (match) {
return hint;
}
} }
return undefined; return undefined;
} }

View file

@ -191,7 +191,9 @@ describe("config view", () => {
const input = container.querySelector(".config-search__input"); const input = container.querySelector(".config-search__input");
expect(input).not.toBeNull(); expect(input).not.toBeNull();
if (!input) {return;} if (!input) {
return;
}
input.value = "gateway"; input.value = "gateway";
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onSearchChange).toHaveBeenCalledWith("gateway"); expect(onSearchChange).toHaveBeenCalledWith("gateway");

View file

@ -299,7 +299,9 @@ function resolveSectionMeta(
description?: string; description?: string;
} { } {
const meta = SECTION_META[key]; const meta = SECTION_META[key];
if (meta) {return meta;} if (meta) {
return meta;
}
return { return {
label: schema?.title ?? humanize(key), label: schema?.title ?? humanize(key),
description: schema?.description ?? "", description: schema?.description ?? "",
@ -312,7 +314,9 @@ function resolveSubsections(params: {
uiHints: ConfigUiHints; uiHints: ConfigUiHints;
}): SubsectionEntry[] { }): SubsectionEntry[] {
const { key, schema, uiHints } = params; const { key, schema, uiHints } = params;
if (!schema || schemaType(schema) !== "object" || !schema.properties) {return [];} if (!schema || schemaType(schema) !== "object" || !schema.properties) {
return [];
}
const entries = Object.entries(schema.properties).map(([subKey, node]) => { const entries = Object.entries(schema.properties).map(([subKey, node]) => {
const hint = hintForPath([key, subKey], uiHints); const hint = hintForPath([key, subKey], uiHints);
const label = hint?.label ?? node.title ?? humanize(subKey); const label = hint?.label ?? node.title ?? humanize(subKey);
@ -328,11 +332,15 @@ function computeDiff(
original: Record<string, unknown> | null, original: Record<string, unknown> | null,
current: Record<string, unknown> | null, current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> { ): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) {return [];} if (!original || !current) {
return [];
}
const changes: Array<{ path: string; from: unknown; to: unknown }> = []; const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
function compare(orig: unknown, curr: unknown, path: string) { function compare(orig: unknown, curr: unknown, path: string) {
if (orig === curr) {return;} if (orig === curr) {
return;
}
if (typeof orig !== typeof curr) { if (typeof orig !== typeof curr) {
changes.push({ path, from: orig, to: curr }); changes.push({ path, from: orig, to: curr });
return; return;
@ -369,7 +377,9 @@ function truncateValue(value: unknown, maxLen = 40): string {
} catch { } catch {
str = String(value); str = String(value);
} }
if (str.length <= maxLen) {return str;} if (str.length <= maxLen) {
return str;
}
return str.slice(0, maxLen - 3) + "..."; return str.slice(0, maxLen - 3) + "...";
} }
@ -392,7 +402,7 @@ export function renderConfig(props: ConfigProps) {
const activeSectionSchema = const activeSectionSchema =
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object" props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
? (analysis.schema.properties?.[props.activeSection]) ? analysis.schema.properties?.[props.activeSection]
: undefined; : undefined;
const activeSectionMeta = props.activeSection const activeSectionMeta = props.activeSection
? resolveSectionMeta(props.activeSection, activeSectionSchema) ? resolveSectionMeta(props.activeSection, activeSectionSchema)

View file

@ -38,16 +38,22 @@ function buildChannelOptions(props: CronProps): string[] {
} }
const seen = new Set<string>(); const seen = new Set<string>();
return options.filter((value) => { return options.filter((value) => {
if (seen.has(value)) {return false;} if (seen.has(value)) {
return false;
}
seen.add(value); seen.add(value);
return true; return true;
}); });
} }
function resolveChannelLabel(props: CronProps, channel: string): string { function resolveChannelLabel(props: CronProps, channel: string): string {
if (channel === "last") {return "last";} if (channel === "last") {
return "last";
}
const meta = props.channelMeta?.find((entry) => entry.id === channel); const meta = props.channelMeta?.find((entry) => entry.id === channel);
if (meta?.label) {return meta.label;} if (meta?.label) {
return meta.label;
}
return props.channelLabels?.[channel] ?? channel; return props.channelLabels?.[channel] ?? channel;
} }
@ -212,8 +218,7 @@ export function renderCron(props: CronProps) {
.value=${props.form.channel || "last"} .value=${props.form.channel || "last"}
@change=${(e: Event) => @change=${(e: Event) =>
props.onFormChange({ props.onFormChange({
channel: (e.target as HTMLSelectElement) channel: (e.target as HTMLSelectElement).value,
.value,
})} })}
> >
${channelOptions.map( ${channelOptions.map(

View file

@ -4,21 +4,29 @@ import type { AppViewState } from "../app-view-state";
function formatRemaining(ms: number): string { function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms); const remaining = Math.max(0, ms);
const totalSeconds = Math.floor(remaining / 1000); const totalSeconds = Math.floor(remaining / 1000);
if (totalSeconds < 60) {return `${totalSeconds}s`;} if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) {return `${minutes}m`;} if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
return `${hours}h`; return `${hours}h`;
} }
function renderMetaRow(label: string, value?: string | null) { function renderMetaRow(label: string, value?: string | null) {
if (!value) {return nothing;} if (!value) {
return nothing;
}
return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`; return html`<div class="exec-approval-meta-row"><span>${label}</span><span>${value}</span></div>`;
} }
export function renderExecApprovalPrompt(state: AppViewState) { export function renderExecApprovalPrompt(state: AppViewState) {
const active = state.execApprovalQueue[0]; const active = state.execApprovalQueue[0];
if (!active) {return nothing;} if (!active) {
return nothing;
}
const request = active.request; const request = active.request;
const remainingMs = active.expiresAtMs - Date.now(); const remainingMs = active.expiresAtMs - Date.now();
const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired"; const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired";

View file

@ -3,7 +3,9 @@ import type { AppViewState } from "../app-view-state";
export function renderGatewayUrlConfirmation(state: AppViewState) { export function renderGatewayUrlConfirmation(state: AppViewState) {
const { pendingGatewayUrl } = state; const { pendingGatewayUrl } = state;
if (!pendingGatewayUrl) {return nothing;} if (!pendingGatewayUrl) {
return nothing;
}
return html` return html`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite"> <div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">

View file

@ -21,14 +21,20 @@ export type LogsProps = {
}; };
function formatTime(value?: string | null) { function formatTime(value?: string | null) {
if (!value) {return "";} if (!value) {
return "";
}
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) {return value;} if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleTimeString(); return date.toLocaleTimeString();
} }
function matchesFilter(entry: LogEntry, needle: string) { function matchesFilter(entry: LogEntry, needle: string) {
if (!needle) {return true;} if (!needle) {
return true;
}
const haystack = [entry.message, entry.subsystem, entry.raw] const haystack = [entry.message, entry.subsystem, entry.raw]
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
@ -40,7 +46,9 @@ export function renderLogs(props: LogsProps) {
const needle = props.filterText.trim().toLowerCase(); const needle = props.filterText.trim().toLowerCase();
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]); const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
const filtered = props.entries.filter((entry) => { const filtered = props.entries.filter((entry) => {
if (entry.level && !props.levelFilters[entry.level]) {return false;} if (entry.level && !props.levelFilters[entry.level]) {
return false;
}
return matchesFilter(entry, needle); return matchesFilter(entry, needle);
}); });
const exportLabel = needle || levelFiltered ? "filtered" : "visible"; const exportLabel = needle || levelFiltered ? "filtered" : "visible";

View file

@ -1,4 +1,4 @@
import { html, nothing } from "lit"; import { html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { icons } from "../icons"; import { icons } from "../icons";
import { toSanitizedMarkdownHtml } from "../markdown"; import { toSanitizedMarkdownHtml } from "../markdown";

View file

@ -328,12 +328,16 @@ function resolveBindingsState(props: NodesProps): BindingState {
} }
function normalizeSecurity(value?: string): ExecSecurity { function normalizeSecurity(value?: string): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") {return value;} if (value === "allowlist" || value === "full" || value === "deny") {
return value;
}
return "deny"; return "deny";
} }
function normalizeAsk(value?: string): ExecAsk { function normalizeAsk(value?: string): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") {return value;} if (value === "always" || value === "off" || value === "on-miss") {
return value;
}
return "on-miss"; return "on-miss";
} }
@ -354,10 +358,14 @@ function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprov
const list = Array.isArray(agentsNode.list) ? agentsNode.list : []; const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = []; const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => { list.forEach((entry) => {
if (!entry || typeof entry !== "object") {return;} if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>; const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : ""; const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {return;} if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined; const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true; const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault }); agents.push({ id, name: name || undefined, isDefault });
@ -374,7 +382,9 @@ function resolveExecApprovalsAgents(
const merged = new Map<string, ExecApprovalsAgentOption>(); const merged = new Map<string, ExecApprovalsAgentOption>();
configAgents.forEach((agent) => merged.set(agent.id, agent)); configAgents.forEach((agent) => merged.set(agent.id, agent));
approvalsAgents.forEach((id) => { approvalsAgents.forEach((id) => {
if (merged.has(id)) {return;} if (merged.has(id)) {
return;
}
merged.set(id, { id }); merged.set(id, { id });
}); });
const agents = Array.from(merged.values()); const agents = Array.from(merged.values());
@ -382,8 +392,12 @@ function resolveExecApprovalsAgents(
agents.push({ id: "main", isDefault: true }); agents.push({ id: "main", isDefault: true });
} }
agents.sort((a, b) => { agents.sort((a, b) => {
if (a.isDefault && !b.isDefault) {return -1;} if (a.isDefault && !b.isDefault) {
if (!a.isDefault && b.isDefault) {return 1;} return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
const aLabel = a.name?.trim() ? a.name : a.id; const aLabel = a.name?.trim() ? a.name : a.id;
const bLabel = b.name?.trim() ? b.name : b.id; const bLabel = b.name?.trim() ? b.name : b.id;
return aLabel.localeCompare(bLabel); return aLabel.localeCompare(bLabel);
@ -395,8 +409,12 @@ function resolveExecApprovalsScope(
selected: string | null, selected: string | null,
agents: ExecApprovalsAgentOption[], agents: ExecApprovalsAgentOption[],
): string { ): string {
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {return EXEC_APPROVALS_DEFAULT_SCOPE;} if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {
if (selected && agents.some((agent) => agent.id === selected)) {return selected;} return EXEC_APPROVALS_DEFAULT_SCOPE;
}
if (selected && agents.some((agent) => agent.id === selected)) {
return selected;
}
return EXEC_APPROVALS_DEFAULT_SCOPE; return EXEC_APPROVALS_DEFAULT_SCOPE;
} }
@ -1010,9 +1028,13 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
for (const node of nodes) { for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : []; const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some((cmd) => String(cmd) === "system.run"); const supports = commands.some((cmd) => String(cmd) === "system.run");
if (!supports) {continue;} if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : ""; const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {continue;} if (!nodeId) {
continue;
}
const displayName = const displayName =
typeof node.displayName === "string" && node.displayName.trim() typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim() ? node.displayName.trim()
@ -1036,9 +1058,13 @@ function resolveExecApprovalsNodes(
(cmd) => (cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set", String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
); );
if (!supports) {continue;} if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : ""; const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {continue;} if (!nodeId) {
continue;
}
const displayName = const displayName =
typeof node.displayName === "string" && node.displayName.trim() typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim() ? node.displayName.trim()
@ -1079,10 +1105,14 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
const agents: BindingAgent[] = []; const agents: BindingAgent[] = [];
list.forEach((entry, index) => { list.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {return;} if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>; const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : ""; const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {return;} if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined; const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true; const isDefault = record.default === true;
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>; const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;

View file

@ -29,10 +29,14 @@ export function renderOverview(props: OverviewProps) {
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a"; 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 = (() => { const authHint = (() => {
if (props.connected || !props.lastError) {return null;} if (props.connected || !props.lastError) {
return null;
}
const lower = props.lastError.toLowerCase(); const lower = props.lastError.toLowerCase();
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed"); const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {return null;} if (!authFailed) {
return null;
}
const hasToken = Boolean(props.settings.token.trim()); const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim()); const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) { if (!hasToken && !hasPassword) {
@ -74,9 +78,13 @@ export function renderOverview(props: OverviewProps) {
`; `;
})(); })();
const insecureContextHint = (() => { const insecureContextHint = (() => {
if (props.connected || !props.lastError) {return null;} if (props.connected || !props.lastError) {
return null;
}
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true; const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext) {return null;} if (isSecureContext) {
return null;
}
const lower = props.lastError.toLowerCase(); const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) { if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null; return null;

View file

@ -42,9 +42,13 @@ const VERBOSE_LEVELS = [
const REASONING_LEVELS = ["", "off", "on", "stream"] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
function normalizeProviderId(provider?: string | null): string { function normalizeProviderId(provider?: string | null): string {
if (!provider) {return "";} if (!provider) {
return "";
}
const normalized = provider.trim().toLowerCase(); const normalized = provider.trim().toLowerCase();
if (normalized === "z.ai" || normalized === "z-ai") {return "zai";} if (normalized === "z.ai" || normalized === "z-ai") {
return "zai";
}
return normalized; return normalized;
} }
@ -57,15 +61,25 @@ function resolveThinkLevelOptions(provider?: string | null): readonly string[] {
} }
function resolveThinkLevelDisplay(value: string, isBinary: boolean): string { function resolveThinkLevelDisplay(value: string, isBinary: boolean): string {
if (!isBinary) {return value;} if (!isBinary) {
if (!value || value === "off") {return value;} return value;
}
if (!value || value === "off") {
return value;
}
return "on"; return "on";
} }
function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | null { function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | null {
if (!value) {return null;} if (!value) {
if (!isBinary) {return value;} return null;
if (value === "on") {return "low";} }
if (!isBinary) {
return value;
}
if (value === "on") {
return "low";
}
return value; return value;
} }

View file

@ -85,8 +85,12 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
...skill.missing.os.map((o) => `os:${o}`), ...skill.missing.os.map((o) => `os:${o}`),
]; ];
const reasons: string[] = []; const reasons: string[] = [];
if (skill.disabled) {reasons.push("disabled");} if (skill.disabled) {
if (skill.blockedByAllowlist) {reasons.push("blocked by allowlist");} reasons.push("disabled");
}
if (skill.blockedByAllowlist) {
reasons.push("blocked by allowlist");
}
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">

View file

@ -6,13 +6,19 @@ const here = path.dirname(fileURLToPath(import.meta.url));
function normalizeBase(input: string): string { function normalizeBase(input: string): string {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) {return "/";} if (!trimmed) {
if (trimmed === "./") {return "./";} return "/";
if (trimmed.endsWith("/")) {return trimmed;} }
if (trimmed === "./") {
return "./";
}
if (trimmed.endsWith("/")) {
return trimmed;
}
return `${trimmed}/`; return `${trimmed}/`;
} }
export default defineConfig(({ command }) => { export default defineConfig(() => {
const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim();
const base = envBase ? normalizeBase(envBase) : "./"; const base = envBase ? normalizeBase(envBase) : "./";
return { return {