Keep Control UI responsive under slow status and history loads

This commit is contained in:
Val Alexander
2026-05-05 19:47:51 -05:00
parent 3f6b481464
commit 60171e8638
18 changed files with 562 additions and 157 deletions

View File

@@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev.
- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev.
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.
- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev.

View File

@@ -96,12 +96,14 @@ Imported themes are stored only in the current browser profile. They are not wri
<AccordionGroup>
<Accordion title="Chat and Talk">
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`).
- Chat history refreshes request a bounded recent window with per-message text caps so large sessions do not force the browser to render a full transcript payload before the chat becomes usable.
- Talk through browser realtime sessions. OpenAI uses direct WebRTC, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use the Gateway relay transport. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio` and forwards `openclaw_agent_consult` provider tool calls through `talk.client.toolCall` for Gateway policy and the larger configured OpenClaw model.
- Stream tool calls + live tool output cards in Chat (agent events).
</Accordion>
<Accordion title="Channels, instances, sessions, dreams">
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
- Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and partial snapshots are labeled when a probe or audit exceeds its UI budget.
- Instances: presence list + refresh (`system-presence`).
- Sessions: list + per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
@@ -127,7 +129,7 @@ Imported themes are stored only in the current browser profile. They are not wri
</Accordion>
<Accordion title="Debug, logs, update">
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`).
- The event log includes Control UI refresh/RPC timings plus browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types.
- The event log includes Control UI refresh/RPC timings, slow chat/config render timings, and browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types.
- Logs: live tail of gateway file logs with filter/export (`logs.tail`).
- Update: run a package/git update + restart (`update.run`) with a restart report, then poll `update.status` after reconnect to verify the running gateway version.

View File

@@ -50,6 +50,8 @@ describe("ChannelsStatusResultSchema", () => {
],
},
channelDefaultAccountId: { discord: "default" },
partial: true,
warnings: ["discord:default probe timed out after 1000ms"],
eventLoop: {
degraded: true,
reasons: ["event_loop_delay", "cpu"],

View File

@@ -644,6 +644,8 @@ export const ChannelsStatusResultSchema = Type.Object(
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
eventLoop: Type.Optional(ChannelEventLoopHealthSchema),
partial: Type.Optional(Type.Boolean()),
warnings: Type.Optional(Type.Array(Type.String())),
},
{ additionalProperties: false },
);

View File

@@ -164,6 +164,54 @@ describe("channelsHandlers channels.status", () => {
);
});
it("returns a partial snapshot when a channel probe exceeds the status budget", async () => {
vi.useFakeTimers();
try {
const autoEnabledConfig = { autoEnabled: true };
const probeAccount = vi.fn(() => new Promise(() => undefined));
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isEnabled: () => true,
isConfigured: async () => true,
},
status: {
probeAccount,
},
},
]);
const respond = vi.fn();
const run = channelsHandlers["channels.status"](
createOptions({ probe: true, timeoutMs: 1000 }, { respond }),
);
await vi.advanceTimersByTimeAsync(1000);
await run;
expect(mocks.buildChannelAccountSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
probe: expect.objectContaining({
timedOut: true,
}),
}),
);
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
partial: true,
warnings: [expect.stringContaining("whatsapp:default probe timed out after 1000ms")],
}),
undefined,
);
} finally {
vi.useRealTimers();
}
});
it("annotates unhealthy channel snapshots and includes event-loop health", async () => {
const now = Date.now();
mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] });

View File

@@ -57,6 +57,58 @@ type ChannelStopPayload = {
const CHANNEL_STATUS_MAX_TIMEOUT_MS = 30_000;
const CHANNEL_STATUS_PROBE_CONCURRENCY = 5;
function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record<string, unknown> {
return {
ok: false,
timedOut: true,
error: `${step} timed out after ${timeoutMs}ms`,
};
}
async function runChannelStatusHook(params: {
accountId: string;
channelId: ChannelId;
step: "audit" | "probe";
timeoutMs: number;
warnings: string[];
run: () => Promise<unknown>;
}): Promise<unknown> {
const timeoutMs = Math.max(1, params.timeoutMs);
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
if (typeof timer === "object" && "unref" in timer) {
timer.unref();
}
});
const result = await Promise.race([
Promise.resolve()
.then(params.run)
.then(
(value) => ({ kind: "value" as const, value }),
(error) => ({ kind: "error" as const, error }),
),
timeout,
]);
if (timer) {
clearTimeout(timer);
}
if (result.kind === "value") {
return result.value;
}
const warningPrefix = `${params.channelId}:${params.accountId} ${params.step}`;
if (result.kind === "timeout") {
params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`);
return channelStatusTimeoutPayload(params.step, timeoutMs);
}
const message = formatForLog(result.error);
params.warnings.push(`${warningPrefix} failed: ${message}`);
return {
ok: false,
error: message,
};
}
function resolveChannelsStatusTimeoutMs(params: { probe: boolean; timeoutMsRaw: unknown }): number {
const fallback = params.probe ? CHANNEL_STATUS_MAX_TIMEOUT_MS : 10_000;
if (typeof params.timeoutMsRaw !== "number" || !Number.isFinite(params.timeoutMsRaw)) {
@@ -198,6 +250,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
const pluginMap = new Map<ChannelId, ChannelPlugin>(
plugins.map((plugin) => [plugin.id, plugin]),
);
const statusWarnings: string[] = [];
const resolveRuntimeSnapshot = (
channelId: ChannelId,
@@ -237,10 +290,18 @@ export const channelsHandlers: GatewayRequestHandlers = {
configured = await plugin.config.isConfigured(account, cfg);
}
if (configured) {
probeResult = await plugin.status.probeAccount({
account,
probeResult = await runChannelStatusHook({
channelId,
accountId,
step: "probe",
timeoutMs,
cfg,
warnings: statusWarnings,
run: () =>
plugin.status!.probeAccount!({
account,
timeoutMs,
cfg,
}),
});
lastProbeAt = Date.now();
}
@@ -252,11 +313,19 @@ export const channelsHandlers: GatewayRequestHandlers = {
configured = await plugin.config.isConfigured(account, cfg);
}
if (configured) {
auditResult = await plugin.status.auditAccount({
account,
auditResult = await runChannelStatusHook({
channelId,
accountId,
step: "audit",
timeoutMs,
cfg,
probe: probeResult,
warnings: statusWarnings,
run: () =>
plugin.status!.auditAccount!({
account,
timeoutMs,
cfg,
probe: probeResult,
}),
});
}
}
@@ -377,6 +446,10 @@ export const channelsHandlers: GatewayRequestHandlers = {
defaultAccountIdMap[result.pluginId] = result.defaultAccountId;
}
}
if (statusWarnings.length > 0) {
payload.partial = true;
payload.warnings = statusWarnings.slice(0, 50);
}
respond(true, payload, undefined);
},

View File

@@ -41,6 +41,7 @@ let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage;
let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory;
let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat;
let refreshChat: typeof import("./app-chat.ts").refreshChat;
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun;
let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage;
@@ -51,6 +52,7 @@ async function loadChatHelpers(): Promise<void> {
steerQueuedChatMessage,
navigateChatInputHistory,
handleAbortChat,
refreshChat,
refreshChatAvatar,
clearPendingQueueItemsForRun,
removeQueuedMessage,
@@ -433,6 +435,55 @@ describe("refreshChatAvatar", () => {
});
});
describe("refreshChat", () => {
beforeAll(async () => {
await loadChatHelpers();
});
it("does not wait for secondary chat metadata refreshes before showing history", async () => {
const previousFetch = globalThis.fetch;
globalThis.fetch = vi.fn(() => new Promise<Response>(() => undefined)) as never;
try {
const request = vi.fn((method: string) => {
if (method === "chat.history") {
return Promise.resolve({
messages: [{ role: "assistant", content: [{ type: "text", text: "ready" }] }],
});
}
return new Promise(() => undefined);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
});
const outcome = await Promise.race([
refreshChat(host).then(() => "resolved" as const),
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 20)),
]);
expect(outcome).toBe("resolved");
expect(host.chatMessages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "ready" }] },
]);
expect(request).toHaveBeenCalledWith(
"sessions.list",
expect.objectContaining({
includeGlobal: true,
includeUnknown: true,
}),
);
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(request).toHaveBeenCalledWith(
"commands.list",
expect.objectContaining({ includeArgs: true, scope: "text" }),
);
} finally {
globalThis.fetch = previousFetch;
}
});
});
describe("handleSendChat", () => {
beforeAll(async () => {
await loadChatHelpers();

View File

@@ -666,8 +666,7 @@ function injectCommandResult(host: ChatHost, content: string) {
}
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
await Promise.all([
loadChatHistory(host as unknown as ChatState),
void Promise.allSettled([
loadSessions(host as unknown as SessionsState, {
activeMinutes: 0,
limit: 0,
@@ -678,6 +677,7 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
refreshChatModels(host),
refreshChatCommands(host),
]);
await loadChatHistory(host as unknown as ChatState);
if (opts?.scheduleScroll !== false) {
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}

View File

@@ -20,6 +20,11 @@ import {
} from "./app-render.helpers.ts";
import { warnQueryToken } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import {
controlUiNowMs,
recordControlUiRenderTiming,
roundedControlUiDurationMs,
} from "./control-ui-performance.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";
@@ -405,6 +410,31 @@ function normalizeScopedConfigSelection(
return { activeSection, activeSubsection };
}
function countTopLevelSchemaProperties(schema: unknown): number {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return 0;
}
const properties = (schema as { properties?: unknown }).properties;
return properties && typeof properties === "object" && !Array.isArray(properties)
? Object.keys(properties).length
: 0;
}
function renderMeasured<T>(
state: AppViewState,
surface: string,
payload: Record<string, unknown>,
render: () => T,
): T {
const startedAtMs = controlUiNowMs();
const result = render();
recordControlUiRenderTiming(state, surface, {
...payload,
durationMs: roundedControlUiDurationMs(controlUiNowMs() - startedAtMs),
});
return result;
}
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
@@ -946,11 +976,22 @@ export function renderApp(state: AppViewState) {
| "includeVirtualSections"
>;
const renderConfigTab = (overrides: ConfigTabOverrides) =>
renderConfig({
...commonConfigProps,
includeVirtualSections: false,
...overrides,
});
renderMeasured(
state,
"config",
{
tab: state.tab,
activeSection: overrides.activeSection,
schemaSectionCount: countTopLevelSchemaProperties(commonConfigProps.schema),
hasSearch: Boolean(overrides.searchQuery?.trim()),
},
() =>
renderConfig({
...commonConfigProps,
includeVirtualSections: false,
...overrides,
}),
);
const configSelection = normalizeMainConfigSelection(
state.configActiveSection,
state.configActiveSubsection,
@@ -2369,129 +2410,140 @@ export function renderApp(state: AppViewState) {
)
: nothing}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
switchChatSession(state, next);
? renderMeasured(
state,
"chat",
{
messageCount: state.chatMessages.length,
toolMessageCount: state.chatToolMessages.length,
streamSegmentCount: state.chatStreamSegments.length,
queueCount: state.chatQueue.length,
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
showToolCalls,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
fallbackStatus: state.fallbackStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
sideResult: state.chatSideResult,
toolMessages: state.chatToolMessages,
streamSegments: state.chatStreamSegments,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
realtimeTalkActive: state.realtimeTalkActive,
realtimeTalkStatus: state.realtimeTalkStatus,
realtimeTalkDetail: state.realtimeTalkDetail,
realtimeTalkTranscript: state.realtimeTalkTranscript,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
onDismissError: () => dismissChatError(state),
sessions: state.sessionsResult,
focusMode: chatFocus,
autoExpandToolCalls: false,
onRefresh: () => {
state.chatSideResult = null;
state.resetToolStream();
return refreshChat(state, { scheduleScroll: false });
},
onToggleFocusMode: () => {
if (state.onboarding) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
getDraft: () => state.chatMessage,
onDraftChange: (next) => state.handleChatDraftChange(next),
onRequestUpdate: requestHostUpdate,
onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
onOpenSessionCheckpoints: () => {
state.sessionsExpandedCheckpointKey = state.sessionKey;
state.setTab("sessions" as import("./navigation.ts").Tab);
void loadSessions(state, {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
});
},
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
canAbort: hasAbortableSessionRun(state),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onQueueSteer: (id) => void state.steerQueuedChatMessage(id),
onDismissSideResult: () => {
state.chatSideResult = null;
},
onNewSession: () => void createChatSession(state),
onClearHistory: async () => {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("sessions.reset", { key: state.sessionKey });
state.chatMessages = [];
state.chatSideResult = null;
state.chatStream = null;
state.chatRunId = null;
await loadChatHistory(state);
} catch (err) {
state.lastError = String(err);
}
},
agentsList: state.agentsList,
currentAgentId: resolvedAgentId ?? "main",
onAgentChange: (agentId: string) => {
switchChatSession(state, buildAgentMainSessionKey({ agentId }));
},
onNavigateToAgent: () => {
state.agentsSelectedId = resolvedAgentId;
state.setTab("agents" as import("./navigation.ts").Tab);
},
onSessionSelect: (key: string) => {
switchChatSession(state, key);
},
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
canvasHostUrl: state.hello?.canvasHostUrl ?? null,
onOpenSidebar: (content) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: effectiveAssistantAvatar,
userName: state.userName ?? null,
userAvatar: state.userAvatar ?? null,
localMediaPreviewRoots: state.localMediaPreviewRoots,
embedSandboxMode: state.embedSandboxMode,
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
basePath: state.basePath ?? "",
})
() =>
renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
switchChatSession(state, next);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
showToolCalls,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
fallbackStatus: state.fallbackStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
sideResult: state.chatSideResult,
toolMessages: state.chatToolMessages,
streamSegments: state.chatStreamSegments,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
realtimeTalkActive: state.realtimeTalkActive,
realtimeTalkStatus: state.realtimeTalkStatus,
realtimeTalkDetail: state.realtimeTalkDetail,
realtimeTalkTranscript: state.realtimeTalkTranscript,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
onDismissError: () => dismissChatError(state),
sessions: state.sessionsResult,
focusMode: chatFocus,
autoExpandToolCalls: false,
onRefresh: () => {
state.chatSideResult = null;
state.resetToolStream();
return refreshChat(state, { scheduleScroll: false });
},
onToggleFocusMode: () => {
if (state.onboarding) {
return;
}
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
getDraft: () => state.chatMessage,
onDraftChange: (next) => state.handleChatDraftChange(next),
onRequestUpdate: requestHostUpdate,
onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
onOpenSessionCheckpoints: () => {
state.sessionsExpandedCheckpointKey = state.sessionKey;
state.setTab("sessions" as import("./navigation.ts").Tab);
void loadSessions(state, {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
});
},
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
canAbort: hasAbortableSessionRun(state),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onQueueSteer: (id) => void state.steerQueuedChatMessage(id),
onDismissSideResult: () => {
state.chatSideResult = null;
},
onNewSession: () => void createChatSession(state),
onClearHistory: async () => {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("sessions.reset", { key: state.sessionKey });
state.chatMessages = [];
state.chatSideResult = null;
state.chatStream = null;
state.chatRunId = null;
await loadChatHistory(state);
} catch (err) {
state.lastError = String(err);
}
},
agentsList: state.agentsList,
currentAgentId: resolvedAgentId ?? "main",
onAgentChange: (agentId: string) => {
switchChatSession(state, buildAgentMainSessionKey({ agentId }));
},
onNavigateToAgent: () => {
state.agentsSelectedId = resolvedAgentId;
state.setTab("agents" as import("./navigation.ts").Tab);
},
onSessionSelect: (key: string) => {
switchChatSession(state, key);
},
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
canvasHostUrl: state.hello?.canvasHostUrl ?? null,
onOpenSidebar: (content) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: effectiveAssistantAvatar,
userName: state.userName ?? null,
userAvatar: state.userAvatar ?? null,
localMediaPreviewRoots: state.localMediaPreviewRoots,
embedSandboxMode: state.embedSandboxMode,
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state),
basePath: state.basePath ?? "",
}),
)
: nothing}
${renderConfigTabForActiveTab()}
${state.tab === "debug"

View File

@@ -837,7 +837,11 @@ function buildAttentionItems(host: SettingsAppHost) {
export async function loadChannelsTab(host: SettingsHost) {
const app = host as unknown as SettingsAppHost;
await Promise.all([loadChannels(app, true), loadConfigSchema(app), loadConfig(app)]);
await Promise.all([
loadChannels(app, true, { softTimeoutMs: 750 }),
loadConfigSchema(app),
loadConfig(app),
]);
}
export async function loadCron(host: SettingsHost) {

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { EventLogEntry } from "./app-events.ts";
import {
recordControlUiPerformanceEvent,
recordControlUiRenderTiming,
startControlUiResponsivenessObserver,
} from "./control-ui-performance.ts";
@@ -53,6 +54,7 @@ function createHost() {
}
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(globalThis, "PerformanceObserver", {
configurable: true,
value: originalPerformanceObserver,
@@ -73,6 +75,39 @@ describe("recordControlUiPerformanceEvent", () => {
});
});
describe("recordControlUiRenderTiming", () => {
it("records slow render timings after the current render turn", async () => {
vi.spyOn(console, "debug").mockImplementation(() => undefined);
const host = createHost();
recordControlUiRenderTiming(host, "chat", { durationMs: 20, messageCount: 150 });
expect(host.eventLogBuffer).toHaveLength(0);
await Promise.resolve();
expect(host.eventLogBuffer).toEqual([
expect.objectContaining({
event: "control-ui.render",
payload: expect.objectContaining({
surface: "chat",
durationMs: 20,
messageCount: 150,
slow: true,
}),
}),
]);
});
it("skips render timings that stay within budget", async () => {
const host = createHost();
recordControlUiRenderTiming(host, "config", { durationMs: 4 });
await Promise.resolve();
expect(host.eventLogBuffer).toHaveLength(0);
});
});
describe("startControlUiResponsivenessObserver", () => {
it("records long animation frames with script attribution", () => {
const observe = vi.fn();

View File

@@ -21,8 +21,11 @@ export type ControlUiRefreshRun = {
const EVENT_LOG_LIMIT = 250;
const SLOW_RPC_MS = 1_000;
const SLOW_RENDER_MS = 16;
const VERY_SLOW_RENDER_MS = 50;
const RESPONSIVENESS_ENTRY_MS = 50;
const RESPONSIVENESS_EVENT_LOG_LIMIT = 50;
const RENDER_EVENT_LOG_LIMIT = 50;
type ControlUiResponsivenessObserver = {
disconnect: () => void;
@@ -221,6 +224,36 @@ export function recordControlUiRpcTiming(
);
}
export function recordControlUiRenderTiming(
host: ControlUiPerformanceHost,
surface: string,
payload: Record<string, unknown>,
) {
const durationMs =
typeof payload.durationMs === "number"
? roundedControlUiDurationMs(payload.durationMs)
: undefined;
if (durationMs == null || durationMs < SLOW_RENDER_MS) {
return;
}
runAfterMicrotask(() => {
recordControlUiPerformanceEvent(
host,
"control-ui.render",
{
surface,
...payload,
durationMs,
slow: true,
},
{
warn: durationMs >= VERY_SLOW_RENDER_MS,
maxBufferedEventsForType: RENDER_EVENT_LOG_LIMIT,
},
);
});
}
function getPerformanceObserverCtor(): PerformanceObserverCtor | null {
const observer = globalThis.PerformanceObserver;
return typeof observer === "function" ? (observer as PerformanceObserverCtor) : null;

View File

@@ -1,5 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { waitWhatsAppLogin, type ChannelsState } from "./channels.ts";
import type { ChannelsStatusSnapshot } from "../types.ts";
import { loadChannels, waitWhatsAppLogin, type ChannelsState } from "./channels.ts";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createState(): ChannelsState {
return {
@@ -46,3 +57,47 @@ describe("channels controller WhatsApp wait", () => {
expect(state.whatsappBusy).toBe(false);
});
});
describe("loadChannels", () => {
it("returns after a soft timeout while preserving the stale snapshot", async () => {
vi.useFakeTimers();
try {
const state = createState();
const previous: ChannelsStatusSnapshot = {
ts: 1,
channelOrder: ["nostr"],
channelLabels: { nostr: "Nostr" },
channels: {},
channelAccounts: {},
channelDefaultAccountId: {},
};
const next: ChannelsStatusSnapshot = {
...previous,
ts: 2,
};
const deferred = createDeferred<ChannelsStatusSnapshot | null>();
const request = vi.mocked(state.client!.request);
request.mockReturnValueOnce(deferred.promise);
state.channelsSnapshot = previous;
state.channelsLastSuccess = 10;
const load = loadChannels(state, true, { softTimeoutMs: 100 });
await vi.advanceTimersByTimeAsync(100);
await load;
expect(state.channelsLoading).toBe(true);
expect(state.channelsSnapshot).toBe(previous);
expect(state.channelsLastSuccess).toBe(10);
deferred.resolve(next);
await Promise.resolve();
await Promise.resolve();
expect(state.channelsLoading).toBe(false);
expect(state.channelsSnapshot).toBe(next);
expect(state.channelsLastSuccess).toEqual(expect.any(Number));
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -7,7 +7,19 @@ import {
export type { ChannelsState };
export async function loadChannels(state: ChannelsState, probe: boolean) {
type LoadChannelsOptions = {
softTimeoutMs?: number;
};
function delay(ms: number): Promise<"timeout"> {
return new Promise((resolve) => setTimeout(() => resolve("timeout"), ms));
}
export async function loadChannels(
state: ChannelsState,
probe: boolean,
options: LoadChannelsOptions = {},
) {
if (!state.client || !state.connected) {
return;
}
@@ -16,23 +28,35 @@ export async function loadChannels(state: ChannelsState, probe: boolean) {
}
state.channelsLoading = true;
state.channelsError = null;
try {
const res = await state.client.request<ChannelsStatusSnapshot | null>("channels.status", {
probe,
timeoutMs: 8000,
});
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
if (isMissingOperatorReadScopeError(err)) {
state.channelsSnapshot = null;
state.channelsError = formatMissingOperatorReadScopeMessage("channel status");
} else {
state.channelsError = String(err);
const refresh = (async () => {
try {
const res = await state.client!.request<ChannelsStatusSnapshot | null>("channels.status", {
probe,
timeoutMs: 8000,
});
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
if (isMissingOperatorReadScopeError(err)) {
state.channelsSnapshot = null;
state.channelsError = formatMissingOperatorReadScopeMessage("channel status");
} else {
state.channelsError = String(err);
}
} finally {
state.channelsLoading = false;
}
} finally {
state.channelsLoading = false;
})();
const softTimeoutMs = options.softTimeoutMs;
if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) {
const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]);
if (outcome === "timeout") {
return;
}
return;
}
await refresh;
}
export async function startWhatsAppLogin(state: ChannelsState, force: boolean) {

View File

@@ -1079,7 +1079,8 @@ describe("loadChatHistory", () => {
expect(request).toHaveBeenCalledWith("chat.history", {
sessionKey: "main",
limit: 200,
limit: 100,
maxChars: 4000,
});
expect(state.chatMessages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },

View File

@@ -21,6 +21,8 @@ import {
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
const CHAT_HISTORY_REQUEST_LIMIT = 100;
const CHAT_HISTORY_REQUEST_MAX_CHARS = 4_000;
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500;
const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
@@ -312,7 +314,8 @@ export async function loadChatHistory(state: ChatState) {
thinkingLevel?: string;
}>("chat.history", {
sessionKey,
limit: 200,
limit: CHAT_HISTORY_REQUEST_LIMIT,
maxChars: CHAT_HISTORY_REQUEST_MAX_CHARS,
});
break;
} catch (err) {

View File

@@ -19,6 +19,8 @@ export type ChannelsStatusSnapshot = {
channels: Record<string, unknown>;
channelAccounts: Record<string, ChannelAccountSnapshot[]>;
channelDefaultAccountId: Record<string, string>;
partial?: boolean;
warnings?: string[];
};
export type ChannelUiMetaEntry = {

View File

@@ -55,6 +55,8 @@ export function renderChannels(props: ChannelsProps) {
}
return a.order - b.order;
});
const showingStaleSnapshot = Boolean(props.loading && props.snapshot && props.lastSuccessAt);
const partialWarnings = props.snapshot?.warnings?.filter((warning) => warning.trim()) ?? [];
return html`
<section class="grid grid-cols-2">
@@ -83,6 +85,21 @@ export function renderChannels(props: ChannelsProps) {
${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : t("common.na")}
</div>
</div>
${showingStaleSnapshot
? html`
<div class="callout info" style="margin-top: 12px;">
Refreshing channel status in the background; showing the last successful snapshot.
</div>
`
: nothing}
${props.snapshot?.partial
? html`
<div class="callout warn" style="margin-top: 12px;">
Some channel checks did not finish before the UI budget.
${partialWarnings.length > 0 ? partialWarnings.slice(0, 3).join("; ") : ""}
</div>
`
: nothing}
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${props.lastError}</div>`
: nothing}