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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,5 @@
import { html, nothing } from "lit";
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 { refreshChatAvatar } from "./app-chat";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
@@ -63,17 +44,9 @@ import {
saveSkillApiKey,
updateSkillEdit,
updateSkillEnabled,
type SkillMessage,
} from "./controllers/skills";
import { icons } from "./icons";
import {
TAB_GROUPS,
iconForTab,
pathForTab,
subtitleForTab,
titleForTab,
type Tab,
} from "./navigation";
import { TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation";
import { renderChannels } from "./views/channels";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
@@ -98,8 +71,12 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar;
if (!candidate) {return undefined;}
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {return candidate;}
if (!candidate) {
return undefined;
}
if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) {
return candidate;
}
return identity?.avatarUrl;
}
@@ -486,7 +463,9 @@ export function renderApp(state: AppViewState) {
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) {return;}
if (state.onboarding) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,

View File

@@ -12,7 +12,9 @@ type ScrollHost = {
};
export function scheduleChatScroll(host: ScrollHost, force = false) {
if (host.chatScrollFrame) {cancelAnimationFrame(host.chatScrollFrame);}
if (host.chatScrollFrame) {
cancelAnimationFrame(host.chatScrollFrame);
}
if (host.chatScrollTimeout != null) {
clearTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
@@ -25,7 +27,9 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
overflowY === "auto" ||
overflowY === "scroll" ||
container.scrollHeight - container.clientHeight > 1;
if (canScroll) {return container;}
if (canScroll) {
return container;
}
}
return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
};
@@ -34,22 +38,32 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
host.chatScrollFrame = requestAnimationFrame(() => {
host.chatScrollFrame = null;
const target = pickScrollTarget();
if (!target) {return;}
if (!target) {
return;
}
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
if (!shouldStick) {return;}
if (force) {host.chatHasAutoScrolled = true;}
if (!shouldStick) {
return;
}
if (force) {
host.chatHasAutoScrolled = true;
}
target.scrollTop = target.scrollHeight;
host.chatUserNearBottom = true;
const retryDelay = force ? 150 : 120;
host.chatScrollTimeout = window.setTimeout(() => {
host.chatScrollTimeout = null;
const latest = pickScrollTarget();
if (!latest) {return;}
if (!latest) {
return;
}
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
if (!shouldStickRetry) {return;}
if (!shouldStickRetry) {
return;
}
latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true;
}, retryDelay);
@@ -58,16 +72,22 @@ export function scheduleChatScroll(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(() => {
host.logsScrollFrame = requestAnimationFrame(() => {
host.logsScrollFrame = null;
const container = host.querySelector(".log-stream") as HTMLElement | null;
if (!container) {return;}
if (!container) {
return;
}
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const shouldStick = force || distanceFromBottom < 80;
if (!shouldStick) {return;}
if (!shouldStick) {
return;
}
container.scrollTop = container.scrollHeight;
});
});
@@ -75,14 +95,18 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) {return;}
if (!container) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200;
}
export function handleLogsScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) {return;}
if (!container) {
return;
}
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80;
}
@@ -93,7 +117,9 @@ export function resetChatScroll(host: ScrollHost) {
}
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 url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
@@ -105,9 +131,13 @@ export function exportLogs(lines: string[], label: string) {
}
export function observeTopbar(host: ScrollHost) {
if (typeof ResizeObserver === "undefined") {return;}
if (typeof ResizeObserver === "undefined") {
return;
}
const topbar = host.querySelector(".topbar");
if (!topbar) {return;}
if (!topbar) {
return;
}
const update = () => {
const { height } = topbar.getBoundingClientRect();
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) {
const trimmed = next.trim();
if (!trimmed) {return;}
if (host.settings.lastActiveSessionKey === trimmed) {return;}
if (!trimmed) {
return;
}
if (host.settings.lastActiveSessionKey === trimmed) {
return;
}
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
}
export function applySettingsFromUrl(host: SettingsHost) {
if (!window.location.search) {return;}
if (!window.location.search) {
return;
}
const params = new URLSearchParams(window.location.search);
const tokenRaw = params.get("token");
const passwordRaw = params.get("password");
@@ -117,20 +123,31 @@ export function applySettingsFromUrl(host: SettingsHost) {
shouldCleanUrl = true;
}
if (!shouldCleanUrl) {return;}
if (!shouldCleanUrl) {
return;
}
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
}
export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) {host.tab = next;}
if (next === "chat") {host.chatHasAutoScrolled = false;}
if (next === "logs") {startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);}
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.tab !== next) {
host.tab = next;
}
if (next === "chat") {
host.chatHasAutoScrolled = false;
}
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);
syncUrlWithTab(host, next, false);
}
@@ -150,12 +167,24 @@ export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTra
}
export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "overview") {await loadOverview(host);}
if (host.tab === "channels") {await loadChannelsTab(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 === "cron") {await loadCron(host);}
if (host.tab === "skills") {await loadSkills(host as unknown as OpenClawApp);}
if (host.tab === "overview") {
await loadOverview(host);
}
if (host.tab === "channels") {
await loadChannelsTab(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 === "cron") {
await loadCron(host);
}
if (host.tab === "skills") {
await loadSkills(host as unknown as OpenClawApp);
}
if (host.tab === "nodes") {
await loadNodes(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() {
if (typeof window === "undefined") {return "";}
if (typeof window === "undefined") {
return "";
}
const configured = window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
if (typeof configured === "string" && configured.trim()) {
return normalizeBasePath(configured);
@@ -200,17 +231,23 @@ export function syncThemeWithSettings(host: SettingsHost) {
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved;
if (typeof document === "undefined") {return;}
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
}
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.themeMediaHandler = (event) => {
if (host.theme !== "system") {return;}
if (host.theme !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light");
};
if (typeof host.themeMedia.addEventListener === "function") {
@@ -224,7 +261,9 @@ export function attachThemeListener(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") {
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
return;
@@ -238,16 +277,22 @@ export function detachThemeListener(host: SettingsHost) {
}
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";
setTabFromRoute(host, resolved);
syncUrlWithTab(host, resolved, replace);
}
export function onPopState(host: SettingsHost) {
if (typeof window === "undefined") {return;}
if (typeof window === "undefined") {
return;
}
const resolved = tabFromPath(window.location.pathname, host.basePath);
if (!resolved) {return;}
if (!resolved) {
return;
}
const url = new URL(window.location.href);
const session = url.searchParams.get("session")?.trim();
@@ -264,18 +309,31 @@ export function onPopState(host: SettingsHost) {
}
export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (host.tab !== next) {host.tab = next;}
if (next === "chat") {host.chatHasAutoScrolled = false;}
if (next === "logs") {startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);}
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);}
if (host.tab !== next) {
host.tab = next;
}
if (next === "chat") {
host.chatHasAutoScrolled = false;
}
if (next === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
} 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) {
if (typeof window === "undefined") {return;}
if (typeof window === "undefined") {
return;
}
const targetPath = normalizePath(pathForTab(tab, host.basePath));
const currentPath = normalizePath(window.location.pathname);
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) {
if (typeof window === "undefined") {return;}
if (typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
url.searchParams.set("session", sessionKey);
if (replace) {window.history.replaceState({}, "", url.toString());}
else {window.history.pushState({}, "", url.toString());}
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
export async function loadOverview(host: SettingsHost) {

View File

@@ -35,25 +35,39 @@ type ToolStreamHost = {
};
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>;
if (typeof record.text === "string") {return record.text;}
if (typeof record.text === "string") {
return record.text;
}
const content = record.content;
if (!Array.isArray(content)) {return null;}
if (!Array.isArray(content)) {
return null;
}
const parts = content
.map((item) => {
if (!item || typeof item !== "object") {return null;}
if (!item || typeof item !== "object") {
return null;
}
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;
})
.filter((part): part is string => Boolean(part));
if (parts.length === 0) {return null;}
if (parts.length === 0) {
return null;
}
return parts.join("\n");
}
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") {
return String(value);
}
@@ -67,11 +81,14 @@ function formatToolOutput(value: unknown): string | null {
try {
text = JSON.stringify(value, null, 2);
} catch {
// oxlint-disable typescript/no-base-to-string
text = String(value);
}
}
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}).`;
}
@@ -99,10 +116,14 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
}
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 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) {
@@ -124,7 +145,9 @@ export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
flushToolStreamSync(host);
return;
}
if (host.toolStreamSyncTimer != null) {return;}
if (host.toolStreamSyncTimer != null) {
return;
}
host.toolStreamSyncTimer = window.setTimeout(
() => flushToolStreamSync(host),
TOOL_STREAM_THROTTLE_MS,
@@ -182,7 +205,9 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) {return;}
if (!payload) {
return;
}
// Handle compaction events
if (payload.stream === "compaction") {
@@ -190,17 +215,29 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
return;
}
if (payload.stream !== "tool") {return;}
if (payload.stream !== "tool") {
return;
}
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.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {return;}
if (host.chatRunId && payload.runId !== host.chatRunId) {return;}
if (!host.chatRunId) {return;}
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (!host.chatRunId) {
return;
}
const data = payload.data ?? {};
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
if (!toolCallId) {return;}
if (!toolCallId) {
return;
}
const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined;
@@ -229,8 +266,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
host.toolStreamOrder.push(toolCallId);
} else {
entry.name = name;
if (args !== undefined) {entry.args = args;}
if (output !== undefined) {entry.output = output;}
if (args !== undefined) {
entry.args = args;
}
if (output !== undefined) {
entry.output = output;
}
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 type { EventLogEntry } from "./app-events";
import type { DevicePairingList } from "./controllers/devices";
@@ -84,10 +84,14 @@ declare global {
const injectedAssistantIdentity = resolveInjectedAssistantIdentity();
function resolveOnboardingMode(): boolean {
if (!window.location.search) {return false;}
if (!window.location.search) {
return false;
}
const params = new URLSearchParams(window.location.search);
const raw = params.get("onboarding");
if (!raw) {return false;}
if (!raw) {
return false;
}
const normalized = raw.trim().toLowerCase();
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") {
const active = this.execApprovalQueue[0];
if (!active || !this.client || this.execApprovalBusy) {return;}
if (!active || !this.client || this.execApprovalBusy) {
return;
}
this.execApprovalBusy = true;
this.execApprovalError = null;
try {
@@ -424,7 +430,9 @@ export class OpenClawApp extends LitElement {
handleGatewayUrlConfirm() {
const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) {return;}
if (!nextGatewayUrl) {
return;
}
this.pendingGatewayUrl = null;
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
...this.settings,
@@ -455,7 +463,9 @@ export class OpenClawApp extends LitElement {
window.clearTimeout(this.sidebarCloseTimer);
}
this.sidebarCloseTimer = window.setTimeout(() => {
if (this.sidebarOpen) {return;}
if (this.sidebarOpen) {
return;
}
this.sidebarContent = null;
this.sidebarError = null;
this.sidebarCloseTimer = null;

View File

@@ -18,10 +18,16 @@ declare global {
}
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();
if (!trimmed) {return undefined;}
if (trimmed.length <= maxLength) {return trimmed;}
if (!trimmed) {
return undefined;
}
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.slice(0, maxLength);
}

View File

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

View File

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

View File

@@ -24,7 +24,9 @@ function extractImages(message: unknown): ImageBlock[] {
if (Array.isArray(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>;
if (b.type === "image") {
@@ -188,12 +190,14 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
function isAvatarUrl(value: string): boolean {
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[]) {
if (images.length === 0) {return nothing;}
if (images.length === 0) {
return nothing;
}
return html`
<div class="chat-message-images">
@@ -251,7 +255,9 @@ function renderGroupedMessage(
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
}
if (!markdown && !hasToolCards && !hasImages) {return nothing;}
if (!markdown && !hasToolCards && !hasImages) {
return nothing;
}
return html`
<div class="${bubbleClasses}">

View File

@@ -20,16 +20,24 @@ const textCache = new WeakMap<object, string | null>();
const thinkingCache = new WeakMap<object, string | null>();
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} \d{2}:\d{2}\b/.test(header)) {return true;}
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} \d{2}:\d{2}\b/.test(header)) {
return true;
}
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
}
export function stripEnvelope(text: string): string {
const match = text.match(ENVELOPE_PREFIX);
if (!match) {return text;}
if (!match) {
return text;
}
const header = match[1] ?? "";
if (!looksLikeEnvelopeHeader(header)) {return text;}
if (!looksLikeEnvelopeHeader(header)) {
return text;
}
return text.slice(match[0].length);
}
@@ -45,7 +53,9 @@ export function extractText(message: unknown): string | null {
const parts = content
.map((p) => {
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;
})
.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 {
if (!message || typeof message !== "object") {return extractText(message);}
if (!message || typeof message !== "object") {
return extractText(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);
textCache.set(obj, value);
return value;
@@ -80,15 +94,21 @@ export function extractThinking(message: unknown): string | null {
const item = p as Record<string, unknown>;
if (item.type === "thinking" && typeof item.thinking === "string") {
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.
const rawText = extractRawText(message);
if (!rawText) {return null;}
if (!rawText) {
return null;
}
const matches = [
...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 {
if (!message || typeof message !== "object") {return extractThinking(message);}
if (!message || typeof message !== "object") {
return extractThinking(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);
thinkingCache.set(obj, value);
return value;
@@ -108,24 +132,34 @@ export function extractThinkingCached(message: unknown): string | null {
export function extractRawText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") {return content;}
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
const parts = content
.map((p) => {
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;
})
.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;
}
export function formatReasoningMarkdown(text: string): string {
const trimmed = text.trim();
if (!trimmed) {return "";}
if (!trimmed) {
return "";
}
const lines = trimmed
.split(/\r?\n/)
.map((line) => line.trim())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,25 +46,35 @@ export type DevicesState = {
};
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) {return;}
if (state.devicesLoading) {return;}
if (!state.client || !state.connected) {
return;
}
if (state.devicesLoading) {
return;
}
state.devicesLoading = true;
if (!opts?.quiet) {state.devicesError = null;}
if (!opts?.quiet) {
state.devicesError = null;
}
try {
const res = (await state.client.request("device.pair.list", {}));
const res = await state.client.request("device.pair.list", {});
state.devicesList = {
pending: Array.isArray(res?.pending) ? res.pending : [],
paired: Array.isArray(res?.paired) ? res.paired : [],
};
} catch (err) {
if (!opts?.quiet) {state.devicesError = String(err);}
if (!opts?.quiet) {
state.devicesError = String(err);
}
} finally {
state.devicesLoading = false;
}
}
export async function approveDevicePairing(state: DevicesState, requestId: string) {
if (!state.client || !state.connected) {return;}
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("device.pair.approve", { requestId });
await loadDevices(state);
@@ -74,9 +84,13 @@ export async function approveDevicePairing(state: DevicesState, requestId: strin
}
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?");
if (!confirmed) {return;}
if (!confirmed) {
return;
}
try {
await state.client.request("device.pair.reject", { requestId });
await loadDevices(state);
@@ -89,9 +103,11 @@ export async function rotateDeviceToken(
state: DevicesState,
params: { deviceId: string; role: string; scopes?: string[] },
) {
if (!state.client || !state.connected) {return;}
if (!state.client || !state.connected) {
return;
}
try {
const res = (await state.client.request("device.token.rotate", params));
const res = await state.client.request("device.token.rotate", params);
if (res?.token) {
const identity = await loadOrCreateDeviceIdentity();
const role = res.role ?? params.role;
@@ -115,9 +131,13 @@ export async function revokeDeviceToken(
state: DevicesState,
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})?`);
if (!confirmed) {return;}
if (!confirmed) {
return;
}
try {
await state.client.request("device.token.revoke", params);
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 {
if (!isRecord(payload)) {return null;}
if (!isRecord(payload)) {
return null;
}
const id = typeof payload.id === "string" ? payload.id.trim() : "";
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() : "";
if (!command) {return null;}
if (!command) {
return null;
}
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
if (!createdAtMs || !expiresAtMs) {return null;}
if (!createdAtMs || !expiresAtMs) {
return null;
}
return {
id,
request: {
@@ -55,9 +63,13 @@ export function parseExecApprovalRequested(payload: unknown): ExecApprovalReques
}
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() : "";
if (!id) {return null;}
if (!id) {
return null;
}
return {
id,
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: {} };
}
const nodeId = target.nodeId.trim();
if (!nodeId) {return null;}
if (!nodeId) {
return null;
}
return { method: "exec.approvals.node.get", params: { nodeId } };
}
@@ -68,7 +70,9 @@ function resolveExecApprovalsSaveRpc(
return { method: "exec.approvals.set", params };
}
const nodeId = target.nodeId.trim();
if (!nodeId) {return null;}
if (!nodeId) {
return null;
}
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
}
@@ -76,8 +80,12 @@ export async function loadExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) {return;}
if (state.execApprovalsLoading) {return;}
if (!state.client || !state.connected) {
return;
}
if (state.execApprovalsLoading) {
return;
}
state.execApprovalsLoading = true;
state.lastError = null;
try {
@@ -86,7 +94,7 @@ export async function loadExecApprovals(
state.lastError = "Select a node before loading exec approvals.";
return;
}
const res = (await state.client.request(rpc.method, rpc.params));
const res = await state.client.request(rpc.method, rpc.params);
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
@@ -109,7 +117,9 @@ export async function saveExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) {return;}
if (!state.client || !state.connected) {
return;
}
state.execApprovalsSaving = true;
state.lastError = null;
try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,9 @@ const STORAGE_KEY = "openclaw-device-identity-v1";
function base64UrlEncode(bytes: Uint8Array): string {
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, "");
}
@@ -27,7 +29,9 @@ function base64UrlDecode(input: string): Uint8Array {
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
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;
}

View File

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

View File

@@ -1,44 +1,70 @@
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
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();
}
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;
if (diff < 0) {return "just now";}
if (diff < 0) {
return "just now";
}
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);
if (min < 60) {return `${min}m ago`;}
if (min < 60) {
return `${min}m ago`;
}
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);
return `${day}d ago`;
}
export function formatDurationMs(ms?: number | null): string {
if (!ms && ms !== 0) {return "n/a";}
if (ms < 1000) {return `${ms}ms`;}
if (!ms && ms !== 0) {
return "n/a";
}
if (ms < 1000) {
return `${ms}ms`;
}
const sec = Math.round(ms / 1000);
if (sec < 60) {return `${sec}s`;}
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {return `${min}m`;}
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 48) {return `${hr}h`;}
if (hr < 48) {
return `${hr}h`;
}
const day = Math.round(hr / 24);
return `${day}d`;
}
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(", ");
}
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))}`;
}

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { OpenClawApp } from "./app";
import "../styles.css";
// oxlint-disable-next-line typescript/unbound-method
const originalConnect = OpenClawApp.prototype.connect;
function mountApp(pathname: string) {
@@ -117,7 +118,9 @@ describe("control UI routing", () => {
const initialContainer = app.querySelector(".chat-thread");
expect(initialContainer).not.toBeNull();
if (!initialContainer) {return;}
if (!initialContainer) {
return;
}
initialContainer.style.maxHeight = "180px";
initialContainer.style.overflow = "auto";
@@ -134,11 +137,15 @@ describe("control UI routing", () => {
const container = app.querySelector(".chat-thread");
expect(container).not.toBeNull();
if (!container) {return;}
if (!container) {
return;
}
const maxScroll = container.scrollHeight - container.clientHeight;
expect(maxScroll).toBeGreaterThan(0);
for (let i = 0; i < 10; i++) {
if (container.scrollTop === maxScroll) {break;}
if (container.scrollTop === maxScroll) {
break;
}
await nextFrame();
}
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]));
export function normalizeBasePath(basePath: string): string {
if (!basePath) {return "";}
if (!basePath) {
return "";
}
let base = basePath.trim();
if (!base.startsWith("/")) {base = `/${base}`;}
if (base === "/") {return "";}
if (base.endsWith("/")) {base = base.slice(0, -1);}
if (!base.startsWith("/")) {
base = `/${base}`;
}
if (base === "/") {
return "";
}
if (base.endsWith("/")) {
base = base.slice(0, -1);
}
return base;
}
export function normalizePath(path: string): string {
if (!path) {return "/";}
if (!path) {
return "/";
}
let normalized = path.trim();
if (!normalized.startsWith("/")) {normalized = `/${normalized}`;}
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
@@ -75,8 +87,12 @@ export function tabFromPath(pathname: string, basePath = ""): Tab | null {
}
}
let normalized = normalizePath(path).toLowerCase();
if (normalized.endsWith("/index.html")) {normalized = "/";}
if (normalized === "/") {return "chat";}
if (normalized.endsWith("/index.html")) {
normalized = "/";
}
if (normalized === "/") {
return "chat";
}
return PATH_TO_TAB.get(normalized) ?? null;
}
@@ -85,9 +101,13 @@ export function inferBasePathFromPathname(pathname: string): string {
if (normalized.endsWith("/index.html")) {
normalized = normalizePath(normalized.slice(0, -"/index.html".length));
}
if (normalized === "/") {return "";}
if (normalized === "/") {
return "";
}
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0) {return "";}
if (segments.length === 0) {
return "";
}
for (let i = 0; i < segments.length; i++) {
const candidate = `/${segments.slice(i).join("/")}`.toLowerCase();
if (PATH_TO_TAB.has(candidate)) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,9 @@ describe("generateUUID", () => {
it("falls back to crypto.getRandomValues", () => {
const id = generateUUID({
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;
},
});

View File

@@ -23,7 +23,9 @@ function uuidFromBytes(bytes: Uint8Array): string {
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
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[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
@@ -32,13 +34,17 @@ function weakRandomBytes(): Uint8Array {
}
function warnWeakCryptoOnce() {
if (warnedWeakCrypto) {return;}
if (warnedWeakCrypto) {
return;
}
warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness");
}
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") {
const bytes = new Uint8Array(16);

View File

@@ -18,7 +18,9 @@ function resolveSchemaNode(
): JsonSchema | null {
let current = schema;
for (const key of path) {
if (!current) {return null;}
if (!current) {
return null;
}
const type = schemaType(current);
if (type === "object") {
const properties = current.properties ?? {};
@@ -34,7 +36,9 @@ function resolveSchemaNode(
return null;
}
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;
current = items ?? null;
continue;

View File

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

View File

@@ -13,8 +13,12 @@ import {
* Truncate a pubkey for display (shows first and last 8 chars)
*/
function truncatePubkey(pubkey: string | null | undefined): string {
if (!pubkey) {return "n/a";}
if (pubkey.length <= 20) {return pubkey;}
if (!pubkey) {
return "n/a";
}
if (pubkey.length <= 20) {
return pubkey;
}
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";
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);
if (sec < 60) {return `${sec}s`;}
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {return `${min}m`;}
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
return `${hr}h`;
}
@@ -15,7 +21,9 @@ export function formatDuration(ms?: number | null) {
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
const snapshot = props.snapshot;
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 configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured;
const running = typeof channelStatus?.running === "boolean" && channelStatus.running;
@@ -39,6 +47,8 @@ export function renderChannelAccountCount(
channelAccounts?: Record<string, ChannelAccountSnapshot[]> | null,
) {
const count = getChannelAccountCount(key, channelAccounts);
if (count < 2) {return nothing;}
if (count < 2) {
return nothing;
}
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@@ -44,7 +44,9 @@ export function renderChannels(props: ChannelsProps) {
order: index,
}))
.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;
});
@@ -236,7 +238,9 @@ function renderGenericChannelCard(
function resolveChannelMetaMap(
snapshot: ChannelsStatusSnapshot | null,
): Record<string, ChannelUiMetaEntry> {
if (!snapshot?.channelMeta?.length) {return {};}
if (!snapshot?.channelMeta?.length) {
return {};
}
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
function hasRecentActivity(account: ChannelAccountSnapshot): boolean {
if (!account.lastInboundAt) {return false;}
if (!account.lastInboundAt) {
return false;
}
return Date.now() - account.lastInboundAt < RECENT_ACTIVITY_THRESHOLD_MS;
}
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 (hasRecentActivity(account)) {return "Active";}
if (hasRecentActivity(account)) {
return "Active";
}
return "No";
}
function deriveConnectedStatus(account: ChannelAccountSnapshot): "Yes" | "No" | "Active" | "n/a" {
if (account.connected === true) {return "Yes";}
if (account.connected === false) {return "No";}
if (account.connected === true) {
return "Yes";
}
if (account.connected === false) {
return "No";
}
// 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";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,29 @@ import type { AppViewState } from "../app-view-state";
function formatRemaining(ms: number): string {
const remaining = Math.max(0, ms);
const totalSeconds = Math.floor(remaining / 1000);
if (totalSeconds < 60) {return `${totalSeconds}s`;}
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) {return `${minutes}m`;}
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
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>`;
}
export function renderExecApprovalPrompt(state: AppViewState) {
const active = state.execApprovalQueue[0];
if (!active) {return nothing;}
if (!active) {
return nothing;
}
const request = active.request;
const remainingMs = active.expiresAtMs - Date.now();
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) {
const { pendingGatewayUrl } = state;
if (!pendingGatewayUrl) {return nothing;}
if (!pendingGatewayUrl) {
return nothing;
}
return html`
<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) {
if (!value) {return "";}
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {return value;}
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleTimeString();
}
function matchesFilter(entry: LogEntry, needle: string) {
if (!needle) {return true;}
if (!needle) {
return true;
}
const haystack = [entry.message, entry.subsystem, entry.raw]
.filter(Boolean)
.join(" ")
@@ -40,7 +46,9 @@ export function renderLogs(props: LogsProps) {
const needle = props.filterText.trim().toLowerCase();
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
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);
});
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 { icons } from "../icons";
import { toSanitizedMarkdownHtml } from "../markdown";

View File

@@ -328,12 +328,16 @@ function resolveBindingsState(props: NodesProps): BindingState {
}
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";
}
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";
}
@@ -354,10 +358,14 @@ function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprov
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => {
if (!entry || typeof entry !== "object") {return;}
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
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 isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault });
@@ -374,7 +382,9 @@ function resolveExecApprovalsAgents(
const merged = new Map<string, ExecApprovalsAgentOption>();
configAgents.forEach((agent) => merged.set(agent.id, agent));
approvalsAgents.forEach((id) => {
if (merged.has(id)) {return;}
if (merged.has(id)) {
return;
}
merged.set(id, { id });
});
const agents = Array.from(merged.values());
@@ -382,8 +392,12 @@ function resolveExecApprovalsAgents(
agents.push({ id: "main", isDefault: true });
}
agents.sort((a, b) => {
if (a.isDefault && !b.isDefault) {return -1;}
if (!a.isDefault && b.isDefault) {return 1;}
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
const aLabel = a.name?.trim() ? a.name : a.id;
const bLabel = b.name?.trim() ? b.name : b.id;
return aLabel.localeCompare(bLabel);
@@ -395,8 +409,12 @@ function resolveExecApprovalsScope(
selected: string | null,
agents: ExecApprovalsAgentOption[],
): string {
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {return EXEC_APPROVALS_DEFAULT_SCOPE;}
if (selected && agents.some((agent) => agent.id === selected)) {return selected;}
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
if (selected && agents.some((agent) => agent.id === selected)) {
return selected;
}
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
@@ -1010,9 +1028,13 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some((cmd) => String(cmd) === "system.run");
if (!supports) {continue;}
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {continue;}
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
@@ -1036,9 +1058,13 @@ function resolveExecApprovalsNodes(
(cmd) =>
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() : "";
if (!nodeId) {continue;}
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
@@ -1079,10 +1105,14 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
const agents: BindingAgent[] = [];
list.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {return;}
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
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 isDefault = record.default === true;
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 tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authHint = (() => {
if (props.connected || !props.lastError) {return null;}
if (props.connected || !props.lastError) {
return null;
}
const lower = props.lastError.toLowerCase();
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 hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
@@ -74,9 +78,13 @@ export function renderOverview(props: OverviewProps) {
`;
})();
const insecureContextHint = (() => {
if (props.connected || !props.lastError) {return null;}
if (props.connected || !props.lastError) {
return null;
}
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext) {return null;}
if (isSecureContext) {
return null;
}
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;

View File

@@ -42,9 +42,13 @@ const VERBOSE_LEVELS = [
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
function normalizeProviderId(provider?: string | null): string {
if (!provider) {return "";}
if (!provider) {
return "";
}
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;
}
@@ -57,15 +61,25 @@ function resolveThinkLevelOptions(provider?: string | null): readonly string[] {
}
function resolveThinkLevelDisplay(value: string, isBinary: boolean): string {
if (!isBinary) {return value;}
if (!value || value === "off") {return value;}
if (!isBinary) {
return value;
}
if (!value || value === "off") {
return value;
}
return "on";
}
function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string | null {
if (!value) {return null;}
if (!isBinary) {return value;}
if (value === "on") {return "low";}
if (!value) {
return null;
}
if (!isBinary) {
return value;
}
if (value === "on") {
return "low";
}
return value;
}

View File

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

View File

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