openclaw/ui/src/ui/app-render.ts

1009 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { html, nothing } from "lit";
import type { AppViewState } from "./app-view-state.ts";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { ChatHost, refreshChatAvatar } from "./app-chat.ts";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
import { OpenClawApp } from "./app.ts";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import {
applyConfig,
ConfigState,
loadConfig,
runUpdate,
saveConfig,
updateConfigFormValue,
removeConfigFormValue,
} from "./controllers/config.ts";
import {
loadCronRuns,
toggleCronJob,
runCronJob,
removeCronJob,
addCronJob,
} from "./controllers/cron.ts";
import { loadDebug, callDebugMethod } from "./controllers/debug.ts";
import {
approveDevicePairing,
loadDevices,
rejectDevicePairing,
revokeDeviceToken,
rotateDeviceToken,
} from "./controllers/devices.ts";
import {
loadExecApprovals,
removeExecApprovalsFormValue,
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals.ts";
import { loadLogs, LogsState } from "./controllers/logs.ts";
import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts";
import { deleteSession, loadSessions, patchSession } from "./controllers/sessions.ts";
import {
installSkill,
loadSkills,
saveSkillApiKey,
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { ConfigUiHints } from "./types.ts";
import { renderAgents } from "./views/agents.ts";
import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts";
import { renderConfig } from "./views/config.ts";
import { renderCron } from "./views/cron.ts";
import { renderDebug } from "./views/debug.ts";
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
import { renderInstances } from "./views/instances.ts";
import { renderLogs } from "./views/logs.ts";
import { renderNodes } from "./views/nodes.ts";
import { renderOverview } from "./views/overview.ts";
import { renderSessions } from "./views/sessions.ts";
import { renderSkills } from "./views/skills.ts";
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
const 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;
}
return identity?.avatarUrl;
}
export function renderApp(state: AppViewState) {
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
const isChat = state.tab === "chat";
const chatFocus = isChat && (state.settings.chatFocusMode || state.onboarding);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const logoBase = normalizeBasePath(state.basePath);
const logoHref = logoBase ? `${logoBase}/favicon.svg` : "/favicon.svg";
const configValue =
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
const resolvedAgentId =
state.agentsSelectedId ??
state.agentsList?.defaultId ??
state.agentsList?.agents?.[0]?.id ??
null;
return html`
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<header class="topbar">
<div class="topbar-left">
<button
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
aria-label="${state.settings.navCollapsed ? "Expand sidebar" : "Collapse sidebar"}"
>
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
<div class="brand">
<div class="brand-logo">
<img src="${logoHref}" alt="OpenClaw" />
</div>
<div class="brand-text">
<div class="brand-title">OPENCLAW</div>
<div class="brand-sub">Gateway Dashboard</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>Health</span>
<span class="mono">${state.connected ? "OK" : "Offline"}</span>
</div>
${renderThemeToggle(state)}
</div>
</header>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${group.label}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
</div>
`;
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">Resources</span>
</div>
<div class="nav-group__items">
<a
class="nav-item nav-item--external"
href="https://docs.openclaw.ai"
target="_blank"
rel="noreferrer"
title="Docs (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">Docs</span>
</a>
</div>
</div>
</aside>
<main class="content ${isChat ? "content--chat" : ""}">
<section class="content-header">
<div>
<div class="page-title">${titleForTab(state.tab)}</div>
<div class="page-sub">${subtitleForTab(state.tab)}</div>
</div>
<div class="page-meta">
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${
state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
(state as unknown as OpenClawApp).resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing
}
${
state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints as ConfigUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) =>
updateConfigFormValue(state as unknown as ConfigState, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing
}
${
state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing
}
${
state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing
}
${
state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: (state.channelsSnapshot?.channelOrder ?? []),
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing
}
${
state.tab === "agents"
? renderAgents({
loading: state.agentsLoading,
error: state.agentsError,
agentsList: state.agentsList,
selectedAgentId: resolvedAgentId,
activePanel: state.agentsPanel,
configForm: configValue,
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
channelsLoading: state.channelsLoading,
channelsError: state.channelsError,
channelsSnapshot: state.channelsSnapshot,
channelsLastSuccess: state.channelsLastSuccess,
cronLoading: state.cronLoading,
cronStatus: state.cronStatus,
cronJobs: state.cronJobs,
cronError: state.cronError,
agentFilesLoading: state.agentFilesLoading,
agentFilesError: state.agentFilesError,
agentFilesList: state.agentFilesList,
agentFileActive: state.agentFileActive,
agentFileContents: state.agentFileContents,
agentFileDrafts: state.agentFileDrafts,
agentFileSaving: state.agentFileSaving,
agentIdentityLoading: state.agentIdentityLoading,
agentIdentityError: state.agentIdentityError,
agentIdentityById: state.agentIdentityById,
agentSkillsLoading: state.agentSkillsLoading,
agentSkillsReport: state.agentSkillsReport,
agentSkillsError: state.agentSkillsError,
agentSkillsAgentId: state.agentSkillsAgentId,
skillsFilter: state.skillsFilter,
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
void loadAgentIdentities(state, agentIds);
}
},
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {
return;
}
state.agentsSelectedId = agentId;
state.agentFilesList = null;
state.agentFilesError = null;
state.agentFilesLoading = false;
state.agentFileActive = null;
state.agentFileContents = {};
state.agentFileDrafts = {};
state.agentSkillsReport = null;
state.agentSkillsError = null;
state.agentSkillsAgentId = null;
void loadAgentIdentity(state, agentId);
if (state.agentsPanel === "files") {
void loadAgentFiles(state, agentId);
}
if (state.agentsPanel === "skills") {
void loadAgentSkills(state, agentId);
}
},
onSelectPanel: (panel) => {
state.agentsPanel = panel;
if (panel === "files" && resolvedAgentId) {
if (state.agentFilesList?.agentId !== resolvedAgentId) {
state.agentFilesList = null;
state.agentFilesError = null;
state.agentFileActive = null;
state.agentFileContents = {};
state.agentFileDrafts = {};
void loadAgentFiles(state, resolvedAgentId);
}
}
if (panel === "skills") {
if (resolvedAgentId) {
void loadAgentSkills(state, resolvedAgentId);
}
}
if (panel === "channels") {
void loadChannels(state, false);
}
if (panel === "cron") {
void state.loadCron();
}
},
onLoadFiles: (agentId) => {
void (async () => {
await loadAgentFiles(state, agentId);
if (state.agentFileActive) {
await loadAgentFileContent(state, agentId, state.agentFileActive, {
force: true,
preserveDraft: true,
});
}
})();
},
onSelectFile: (name) => {
state.agentFileActive = name;
if (!resolvedAgentId) {
return;
}
void loadAgentFileContent(state, resolvedAgentId, name);
},
onFileDraftChange: (name, content) => {
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: content };
},
onFileReset: (name) => {
const base = state.agentFileContents[name] ?? "";
state.agentFileDrafts = { ...state.agentFileDrafts, [name]: base };
},
onFileSave: (name) => {
if (!resolvedAgentId) {
return;
}
const content =
state.agentFileDrafts[name] ?? state.agentFileContents[name] ?? "";
void saveAgentFile(state, resolvedAgentId, name, content);
},
onToolsProfileChange: (agentId, profile, clearAllow) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "tools"];
if (profile) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "profile"],
profile,
);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
...basePath,
"profile",
]);
}
if (clearAllow) {
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "allow"]);
}
},
onToolsOverridesChange: (agentId, alsoAllow, deny) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "tools"];
if (alsoAllow.length > 0) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "alsoAllow"],
alsoAllow,
);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
...basePath,
"alsoAllow",
]);
}
if (deny.length > 0) {
updateConfigFormValue(
state as unknown as ConfigState,
[...basePath, "deny"],
deny,
);
} else {
removeConfigFormValue(state as unknown as ConfigState, [...basePath, "deny"]);
}
},
onConfigReload: () => loadConfig(state as unknown as ConfigState),
onConfigSave: () => saveConfig(state as unknown as ConfigState),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => {
if (resolvedAgentId) {
void loadAgentSkills(state, resolvedAgentId);
}
},
onAgentSkillToggle: (agentId, skillName, enabled) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const entry = list[index] as { skills?: unknown };
const normalizedSkill = skillName.trim();
if (!normalizedSkill) {
return;
}
const allSkills =
state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ??
[];
const existing = Array.isArray(entry.skills)
? entry.skills.map((name) => String(name).trim()).filter(Boolean)
: undefined;
const base = existing ?? allSkills;
const next = new Set(base);
if (enabled) {
next.add(normalizedSkill);
} else {
next.delete(normalizedSkill);
}
updateConfigFormValue(
state as unknown as ConfigState,
["agents", "list", index, "skills"],
[...next],
);
},
onAgentSkillsClear: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
removeConfigFormValue(state as unknown as ConfigState, [
"agents",
"list",
index,
"skills",
]);
},
onAgentSkillsDisableAll: (agentId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
updateConfigFormValue(
state as unknown as ConfigState,
["agents", "list", index, "skills"],
[],
);
},
onModelChange: (agentId, modelId) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "model"];
if (!modelId) {
removeConfigFormValue(state as unknown as ConfigState, basePath);
return;
}
const entry = list[index] as { model?: unknown };
const existing = entry?.model;
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const fallbacks = (existing as { fallbacks?: unknown }).fallbacks;
const next = {
primary: modelId,
...(Array.isArray(fallbacks) ? { fallbacks } : {}),
};
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
} else {
updateConfigFormValue(state as unknown as ConfigState, basePath, modelId);
}
},
onModelFallbacksChange: (agentId, fallbacks) => {
if (!configValue) {
return;
}
const list = (configValue as { agents?: { list?: unknown[] } }).agents?.list;
if (!Array.isArray(list)) {
return;
}
const index = list.findIndex(
(entry) =>
entry &&
typeof entry === "object" &&
"id" in entry &&
(entry as { id?: string }).id === agentId,
);
if (index < 0) {
return;
}
const basePath = ["agents", "list", index, "model"];
const entry = list[index] as { model?: unknown };
const normalized = fallbacks.map((name) => name.trim()).filter(Boolean);
const existing = entry.model;
const resolvePrimary = () => {
if (typeof existing === "string") {
return existing.trim() || null;
}
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
const primary = (existing as { primary?: unknown }).primary;
if (typeof primary === "string") {
const trimmed = primary.trim();
return trimmed || null;
}
}
return null;
};
const primary = resolvePrimary();
if (normalized.length === 0) {
if (primary) {
updateConfigFormValue(state as unknown as ConfigState, basePath, primary);
} else {
removeConfigFormValue(state as unknown as ConfigState, basePath);
}
return;
}
const next = primary
? { primary, fallbacks: normalized }
: { fallbacks: normalized };
updateConfigFormValue(state as unknown as ConfigState, basePath, next);
},
})
: nothing
}
${
state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing
}
${
state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm:
state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state as unknown as ConfigState),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(
state as unknown as ConfigState,
["tools", "exec", "node"],
nodeId,
);
} else {
removeConfigFormValue(state as unknown as ConfigState, [
"tools",
"exec",
"node",
]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state as unknown as ConfigState, basePath, nodeId);
} else {
removeConfigFormValue(state as unknown as ConfigState, basePath);
}
},
onSaveBindings: () => saveConfig(state as unknown as ConfigState),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing
}
${
state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatQueue = [];
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state as unknown as ChatState);
void refreshChatAvatar(state as unknown as ChatHost);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: null,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
return Promise.all([
loadChatHistory(state as unknown as ChatState),
refreshChatAvatar(state as unknown as ChatHost),
]);
},
onToggleFocusMode: () => {
if (state.onboarding) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => (state as unknown as OpenClawApp).handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => (state as unknown as OpenClawApp).handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void (state as unknown as OpenClawApp).handleAbortChat(),
onQueueRemove: (id) => (state as unknown as OpenClawApp).removeQueuedMessage(id),
onNewSession: () =>
(state as unknown as OpenClawApp).handleSendChat("/new", { restoreDraft: true }),
showNewMessages: state.chatNewMessagesBelow,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
sidebarOpen: (state as unknown as OpenClawApp).sidebarOpen,
sidebarContent: (state as unknown as OpenClawApp).sidebarContent,
sidebarError: (state as unknown as OpenClawApp).sidebarError,
splitRatio: (state as unknown as OpenClawApp).splitRatio,
onOpenSidebar: (content: string) =>
(state as unknown as OpenClawApp).handleOpenSidebar(content),
onCloseSidebar: () => (state as unknown as OpenClawApp).handleCloseSidebar(),
onSplitRatioChange: (ratio: number) =>
(state as unknown as OpenClawApp).handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing
}
${
state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints as ConfigUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: (state as unknown as OpenClawApp).configSearchQuery,
activeSection: (state as unknown as OpenClawApp).configActiveSection,
activeSubsection: (state as unknown as OpenClawApp).configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) =>
updateConfigFormValue(state as unknown as OpenClawApp, path, value),
onSearchChange: (query) =>
((state as unknown as OpenClawApp).configSearchQuery = query),
onSectionChange: (section) => {
(state as unknown as OpenClawApp).configActiveSection = section;
(state as unknown as OpenClawApp).configActiveSubsection = null;
},
onSubsectionChange: (section) =>
((state as unknown as OpenClawApp).configActiveSubsection = section),
onReload: () => loadConfig(state as unknown as OpenClawApp),
onSave: () => saveConfig(state as unknown as OpenClawApp),
onApply: () => applyConfig(state as unknown as OpenClawApp),
onUpdate: () => runUpdate(state as unknown as OpenClawApp),
})
: nothing
}
${
state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing
}
${
state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state as unknown as LogsState, { reset: true }),
onExport: (lines, label) =>
(state as unknown as OpenClawApp).exportLogs(lines, label),
onScroll: (event) => (state as unknown as OpenClawApp).handleLogsScroll(event),
})
: nothing
}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
</div>
`;
}