diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bbe529eea..ee4a770ee9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 5b0eb77a749..d6b3c4a4df1 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -96,12 +96,14 @@ Imported themes are stored only in the current browser profile. They are not wri - 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). - 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 - 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. diff --git a/src/gateway/protocol/channels.schema.test.ts b/src/gateway/protocol/channels.schema.test.ts index 6d415afddb7..5ed56be50d1 100644 --- a/src/gateway/protocol/channels.schema.test.ts +++ b/src/gateway/protocol/channels.schema.test.ts @@ -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"], diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index d27c55b33fb..1042a1e2b82 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -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 }, ); diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 0df3e4520b9..d9c6fc99711 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -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: [] }); diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index 47bd45c2e12..f21efb97e36 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -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 { + 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; +}): Promise { + const timeoutMs = Math.max(1, params.timeoutMs); + let timer: ReturnType | 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( 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); }, diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 5639d6b6feb..3bf97456c73 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -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 { 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(() => 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(); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index dc77c4ba914..89aaa89f183 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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[0]); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a13d86ec74f..fd9787da86f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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( + state: AppViewState, + surface: string, + payload: Record, + 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" diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 703bce75005..37906e489ee 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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) { diff --git a/ui/src/ui/control-ui-performance.test.ts b/ui/src/ui/control-ui-performance.test.ts index 80368a47af6..1c8cc7df4ce 100644 --- a/ui/src/ui/control-ui-performance.test.ts +++ b/ui/src/ui/control-ui-performance.test.ts @@ -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(); diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts index e34e38b892a..83f6bf53f77 100644 --- a/ui/src/ui/control-ui-performance.ts +++ b/ui/src/ui/control-ui-performance.ts @@ -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, +) { + 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; diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts index 857320f6e22..2db07d32ed4 100644 --- a/ui/src/ui/controllers/channels.test.ts +++ b/ui/src/ui/controllers/channels.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + 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(); + } + }); +}); diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index 2c5b2d0b887..c80267bd925 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -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("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("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) { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 6fcaa480d0f..48b3be7ed1e 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -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" }] }, diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 9e6647f1fa3..2d967c819cb 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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) { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index d222b298e0c..55ffdf1885a 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -19,6 +19,8 @@ export type ChannelsStatusSnapshot = { channels: Record; channelAccounts: Record; channelDefaultAccountId: Record; + partial?: boolean; + warnings?: string[]; }; export type ChannelUiMetaEntry = { diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 84c23d0a17c..73a00bf74e3 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -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`
@@ -83,6 +85,21 @@ export function renderChannels(props: ChannelsProps) { ${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : t("common.na")} + ${showingStaleSnapshot + ? html` +
+ Refreshing channel status in the background; showing the last successful snapshot. +
+ ` + : nothing} + ${props.snapshot?.partial + ? html` +
+ Some channel checks did not finish before the UI budget. + ${partialWarnings.length > 0 ? partialWarnings.slice(0, 3).join("; ") : ""} +
+ ` + : nothing} ${props.lastError ? html`
${props.lastError}
` : nothing}