mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
Keep Control UI responsive under slow status and history loads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }] },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user