fix(control-ui): keep channel statuses responsive

Summary:
- Keep Channels responsive by opening on cached/runtime snapshots, bounding live probes, and preventing stale slow probe results from replacing newer snapshots.
- Reduce Control UI churn by scoping Nodes polling to the active Nodes tab, debouncing sessions.changed reconciliation, and bounding secondary chat/session refreshes.
- Scope config schema analysis before section-limited renders so excluded root sections are not fully analyzed.

Verification:
- pnpm test ui/src/ui/app-channels.test.ts ui/src/ui/controllers/channels.test.ts ui/src/ui/app-settings.refresh-active-tab.node.test.ts ui/src/ui/app-gateway.sessions.node.test.ts ui/src/ui/app-lifecycle-connect.node.test.ts ui/src/ui/controllers/sessions.test.ts ui/src/ui/views/config.browser.test.ts src/gateway/server-methods/channels.status.test.ts src/gateway/control-ui.http.test.ts ui/src/ui/app-polling.node.test.ts ui/src/ui/app-gateway-chat-load.node.test.ts ui/src/ui/app-gateway.node.test.ts ui/src/ui/app-chat.test.ts ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-lifecycle.node.test.ts
- pnpm exec oxfmt --check --threads=1 <changed files>
- git diff --check origin/main...HEAD
- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json <changed TypeScript files>
- pnpm changed:lanes --json

Note: local pnpm check:changed reached core lint and failed on src/gateway/server-methods/nodes.invoke-wake.test.ts, which is unchanged in this PR and already present on current origin/main; changed-file lint passed under the same repo wrapper.
This commit is contained in:
Val Alexander
2026-05-11 10:37:35 -05:00
committed by GitHub
parent 678c2c070d
commit 6b3cd9043e
26 changed files with 654 additions and 94 deletions

View File

@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev.
- Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev.
- Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS.
- Tools/media: preserve implicit allow-all semantics from `tools.alsoAllow`-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing `tools.allow: ["*", ...]`. Fixes #77841. Thanks @trialanderrorstudios.
@@ -396,6 +397,7 @@ Docs: https://docs.openclaw.ai
- Gateway/performance: reuse the compatible plugin metadata snapshot across dashboard and channel agent turns so auto-enabled runtime config does not repeatedly rescan plugin metadata before provider calls. Thanks @shakkernerd.
- Gateway/performance: reuse current plugin metadata for provider activation, auth/env candidate lookup, and bundle settings during dashboard and channel agent turns while keeping the configless secret-target cache unscoped and refusing stale unscoped reuse when plugin discovery roots differ. Thanks @shakkernerd.
- Gateway/performance: avoid resolving plugin auto-enable metadata twice in one runtime config pass, reducing repeated dashboard turn metadata scans. Thanks @shakkernerd.
- Control UI/performance: pre-scope config tab schemas before rendering, load Channels with cached/runtime status before manual probes, preserve channel rows through failed status summaries, and keep stale slow probes from replacing newer snapshots. Thanks @BunsDev.
- Auth/providers: pass `config` and `workspaceDir` lookup context through to provider-id resolution so workspace-scoped auth aliases resolve correctly when no explicit alias map is supplied. Thanks @shakkernerd.
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and opt-in sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
- QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence.

View File

@@ -44,6 +44,11 @@ vi.mock("../../infra/channel-activity.js", () => ({
import { channelsHandlers } from "./channels.js";
function getSuccessPayload(respond: ReturnType<typeof vi.fn>): Record<string, unknown> {
expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined);
return respond.mock.calls[0]?.[1] as Record<string, unknown>;
}
function createOptions(
params: Record<string, unknown>,
overrides?: Partial<GatewayRequestHandlerOptions>,
@@ -172,6 +177,55 @@ describe("channelsHandlers channels.status", () => {
expect(probeArgs.cfg).toBe(autoEnabledConfig);
});
it("preserves channel account rows when a live probe throws", async () => {
const autoEnabledConfig = { autoEnabled: true };
const probeAccount = vi.fn(async () => {
throw new Error("probe failed");
});
const respond = vi.fn();
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.buildChannelAccountSnapshot.mockImplementation(async ({ accountId, probe }) => ({
accountId,
configured: true,
probe,
}));
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isEnabled: () => true,
isConfigured: async () => true,
},
status: {
probeAccount,
},
},
]);
await channelsHandlers["channels.status"](
createOptions({ probe: true, timeoutMs: 1000 }, { respond }),
);
const payload = getSuccessPayload(respond);
const channelAccounts = payload.channelAccounts as Record<
string,
Array<Record<string, unknown>>
>;
expect(channelAccounts.whatsapp).toEqual([
expect.objectContaining({
accountId: "default",
lastError: expect.stringContaining("probe failed"),
lastProbeAt: expect.any(Number),
probe: expect.objectContaining({
ok: false,
error: expect.stringContaining("probe failed"),
}),
}),
]);
});
it("returns a partial snapshot when a channel probe exceeds the status budget", async () => {
vi.useFakeTimers();
try {
@@ -211,6 +265,47 @@ describe("channelsHandlers channels.status", () => {
}
});
it("falls back to account-derived channel summaries when summary building fails", async () => {
const autoEnabledConfig = { autoEnabled: true };
const respond = vi.fn();
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
mocks.buildChannelAccountSnapshot.mockResolvedValue({
accountId: "default",
configured: true,
});
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isEnabled: () => true,
isConfigured: async () => true,
},
status: {
buildChannelSummary: async () => {
throw new Error("summary failed");
},
},
},
]);
await channelsHandlers["channels.status"](
createOptions({ probe: false, timeoutMs: 1000 }, { respond }),
);
const payload = getSuccessPayload(respond);
expect(payload.channels).toEqual({
whatsapp: expect.objectContaining({
configured: true,
lastError: expect.stringContaining("summary failed"),
}),
});
expect(payload.channelAccounts).toEqual({
whatsapp: [expect.objectContaining({ accountId: "default", configured: true })],
});
});
it("annotates unhealthy channel snapshots and includes event-loop health", async () => {
const now = Date.now();
mocks.applyPluginAutoEnable.mockReturnValue({ config: { autoEnabled: true }, changes: [] });

View File

@@ -65,15 +65,16 @@ function channelStatusTimeoutPayload(step: string, timeoutMs: number): Record<st
};
}
async function runChannelStatusHook(params: {
accountId: string;
channelId: ChannelId;
step: "audit" | "probe";
type TimeoutRaceResult<T> =
| { kind: "value"; value: T }
| { kind: "error"; error: unknown }
| { kind: "timeout" };
async function raceWithTimeout<T>(params: {
timeoutMs: number;
warnings: string[];
run: () => Promise<unknown>;
}): Promise<unknown> {
const timeoutMs = Math.max(1, params.timeoutMs);
run: () => Promise<T> | T;
}): Promise<TimeoutRaceResult<T>> {
const timeoutMs = params.timeoutMs;
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -93,6 +94,22 @@ async function runChannelStatusHook(params: {
if (timer) {
clearTimeout(timer);
}
return result;
}
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);
const result = await raceWithTimeout({
timeoutMs,
run: params.run,
});
if (result.kind === "value") {
return result.value;
}
@@ -109,6 +126,46 @@ async function runChannelStatusHook(params: {
};
}
type ChannelStatusSummaryOutcome =
| { ok: true; value: unknown }
| { ok: false; error: string; timedOut?: boolean };
async function runChannelStatusSummary(params: {
channelId: ChannelId;
timeoutMs: number;
warnings: string[];
run: () => unknown;
}): Promise<ChannelStatusSummaryOutcome> {
const timeoutMs = Math.max(1, params.timeoutMs);
const result = await raceWithTimeout({
timeoutMs,
run: params.run,
});
const warningPrefix = `${params.channelId} summary`;
if (result.kind === "value") {
return { ok: true, value: result.value };
}
if (result.kind === "timeout") {
const error = `summary timed out after ${timeoutMs}ms`;
params.warnings.push(`${warningPrefix} timed out after ${timeoutMs}ms`);
return { ok: false, timedOut: true, error };
}
const message = formatForLog(result.error);
params.warnings.push(`${warningPrefix} failed: ${message}`);
return { ok: false, error: message };
}
function channelStatusFailureMessage(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (record.ok !== false || typeof record.error !== "string" || record.error.length === 0) {
return null;
}
return record.error;
}
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)) {
@@ -338,6 +395,11 @@ export const channelsHandlers: GatewayRequestHandlers = {
probe: probeResult,
audit: auditResult,
});
const hookError =
channelStatusFailureMessage(auditResult) ?? channelStatusFailureMessage(probeResult);
if (hookError && !snapshot.lastError) {
snapshot.lastError = hookError;
}
if (lastProbeAt) {
snapshot.lastProbeAt = lastProbeAt;
}
@@ -421,20 +483,30 @@ export const channelsHandlers: GatewayRequestHandlers = {
await buildChannelAccounts(plugin.id);
const fallbackAccount =
resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId);
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account: fallbackAccount,
cfg,
defaultAccountId,
snapshot:
defaultAccount ??
({
accountId: defaultAccountId,
} as ChannelAccountSnapshot),
})
: {
configured: defaultAccount?.configured ?? false,
};
const fallbackSummary = (lastError?: string) => ({
configured: defaultAccount?.configured ?? false,
...(lastError ? { lastError } : {}),
});
let summary: unknown = fallbackSummary();
if (plugin.status?.buildChannelSummary) {
const summaryResult = await runChannelStatusSummary({
channelId: plugin.id,
timeoutMs,
warnings: statusWarnings,
run: () =>
plugin.status!.buildChannelSummary!({
account: fallbackAccount,
cfg,
defaultAccountId,
snapshot:
defaultAccount ??
({
accountId: defaultAccountId,
} as ChannelAccountSnapshot),
}),
});
summary = summaryResult.ok ? summaryResult.value : fallbackSummary(summaryResult.error);
}
return { pluginId: plugin.id, summary, accounts, defaultAccountId };
}),
limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : plugins.length || 1,

View File

@@ -576,9 +576,11 @@ describe("refreshChat", () => {
"sessions.list",
"sessions list payload",
);
expect(sessionsListPayload.activeMinutes).toBe(120);
expect(sessionsListPayload.agentId).toBe("main");
expect(sessionsListPayload.includeGlobal).toBe(true);
expect(sessionsListPayload.includeUnknown).toBe(true);
expect(sessionsListPayload.limit).toBe(100);
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
const commandsListPayload = findRequestPayload(
request as unknown as MockCallSource,

View File

@@ -84,6 +84,7 @@ export type ChatAbortOptions = {
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
export const CHAT_SESSIONS_REFRESH_LIMIT = 100;
export {
handleChatDraftChange,
handleChatInputHistoryKey,
@@ -768,8 +769,8 @@ export async function refreshChat(
});
const secondaryRefresh = Promise.allSettled([
loadSessions(host as unknown as SessionsState, {
activeMinutes: 0,
limit: 0,
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
limit: CHAT_SESSIONS_REFRESH_LIMIT,
includeGlobal: true,
includeUnknown: true,
agentId: resolveAgentIdForSession(host) ?? undefined,

View File

@@ -58,6 +58,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 60,
CHAT_SESSIONS_REFRESH_LIMIT: 100,
clearPendingQueueItemsForRun: vi.fn(),
flushChatQueueForEvent: vi.fn(),
refreshChatAvatar: refreshChatAvatarMock,
@@ -217,4 +218,14 @@ describe("connectGateway chat load startup work", () => {
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
expect(refreshChatAvatarMock).toHaveBeenCalledWith(host);
});
it("lets the active tab refresh own node and device loading after hello", async () => {
const { host, client } = connectHost("overview");
client.emitHello();
await vi.waitFor(() => expect(refreshActiveTabMock).toHaveBeenCalledWith(host));
expect(loadNodesMock).not.toHaveBeenCalled();
expect(loadDevicesMock).not.toHaveBeenCalled();
});
});

View File

@@ -450,6 +450,23 @@ describe("connectGateway", () => {
]);
});
it("clears pending session reload timers when the active client closes", () => {
vi.useFakeTimers();
try {
const { host, client } = connectHostGateway();
const pendingReload = vi.fn();
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
client.emitClose({ code: 1005 });
expect(host.sessionsChangedReloadTimer).toBeNull();
vi.advanceTimersByTime(1_000);
expect(pendingReload).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("preserves pending approval requests across reconnect", () => {
const host = createHost();
host.execApprovalQueue = [

View File

@@ -1,5 +1,5 @@
// @vitest-environment node
import { afterAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
const loadSessionsMock = vi.fn();
const loadChatHistoryMock = vi.fn();
@@ -8,6 +8,7 @@ const handleChatEventMock = vi.fn(() => "idle");
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 10,
CHAT_SESSIONS_REFRESH_LIMIT: 25,
clearPendingQueueItemsForRun: vi.fn(),
flushChatQueueForEvent: vi.fn(),
refreshChatAvatar: vi.fn(),
@@ -96,7 +97,7 @@ function createHost() {
},
password: "",
clientInstanceId: "instance-test",
client: null,
client: {},
connected: true,
hello: null,
lastError: null,
@@ -132,6 +133,10 @@ function createHost() {
}
describe("handleGatewayEvent sessions.changed", () => {
afterEach(() => {
vi.useRealTimers();
});
it("scopes post-chat final session refreshes to the run's agent", () => {
loadSessionsMock.mockReset();
handleChatEventMock.mockReset().mockReturnValue("final");
@@ -149,6 +154,7 @@ describe("handleGatewayEvent sessions.changed", () => {
expect(loadSessionsMock).toHaveBeenCalledWith(host, {
activeMinutes: 10,
agentId: "ops",
limit: 25,
});
});
@@ -175,7 +181,8 @@ describe("handleGatewayEvent sessions.changed", () => {
expect(loadSessionsMock).not.toHaveBeenCalled();
});
it("reloads sessions when a change event cannot be applied locally", () => {
it("debounces session reloads when a change event cannot be applied locally", () => {
vi.useFakeTimers();
loadSessionsMock.mockReset();
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
const host = createHost();
@@ -187,9 +194,77 @@ describe("handleGatewayEvent sessions.changed", () => {
seq: 1,
});
expect(loadSessionsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(4_999);
expect(loadSessionsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(loadSessionsMock).toHaveBeenCalledWith(host);
});
it("coalesces unapplied session change reloads into one reconciliation", () => {
vi.useFakeTimers();
loadSessionsMock.mockReset();
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
const host = createHost();
handleGatewayEvent(host, {
type: "event",
event: "sessions.changed",
payload: { sessionKey: "agent:main:a", reason: "cleanup" },
seq: 1,
});
vi.advanceTimersByTime(2_500);
handleGatewayEvent(host, {
type: "event",
event: "sessions.changed",
payload: { sessionKey: "agent:main:b", reason: "cleanup" },
seq: 2,
});
vi.advanceTimersByTime(4_999);
expect(loadSessionsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
expect(loadSessionsMock).toHaveBeenCalledWith(host);
});
it("skips a delayed session reload after the user returns to chat", () => {
vi.useFakeTimers();
loadSessionsMock.mockReset();
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
const host = createHost();
handleGatewayEvent(host, {
type: "event",
event: "sessions.changed",
payload: { sessionKey: "agent:main:main", reason: "cleanup" },
seq: 1,
});
host.tab = "chat";
vi.advanceTimersByTime(5_000);
expect(loadSessionsMock).not.toHaveBeenCalled();
});
it("skips a delayed session reload after disconnect", () => {
vi.useFakeTimers();
loadSessionsMock.mockReset();
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: false });
const host = createHost();
handleGatewayEvent(host, {
type: "event",
event: "sessions.changed",
payload: { sessionKey: "agent:main:main", reason: "cleanup" },
seq: 1,
});
host.connected = false;
host.client = null;
vi.advanceTimersByTime(5_000);
expect(loadSessionsMock).not.toHaveBeenCalled();
});
it("does not reload sessions for applied message-phase session patches to existing rows", () => {
loadSessionsMock.mockReset();
applySessionsChangedEventMock.mockReset().mockReturnValue({ applied: true, change: "updated" });

View File

@@ -5,6 +5,7 @@ import {
import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
import {
CHAT_SESSIONS_ACTIVE_MINUTES,
CHAT_SESSIONS_REFRESH_LIMIT,
clearPendingQueueItemsForRun,
flushChatQueueForEvent,
refreshChatAvatar,
@@ -45,7 +46,6 @@ import {
removeExecApproval,
} from "./controllers/exec-approval.ts";
import { loadHealthState, type HealthState } from "./controllers/health.ts";
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
import {
applySessionsChangedEvent,
loadSessions,
@@ -110,6 +110,7 @@ type GatewayHost = {
execApprovalError: string | null;
updateAvailable: UpdateAvailable | null;
reconcileWebPushState?: () => Promise<void> | void;
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
};
type GatewayHostWithDeferredSessionMessageReload = GatewayHost & {
@@ -133,6 +134,8 @@ type GatewayHostWithSideResults = GatewayHost & {
chatSideResultTerminalRuns?: Set<string>;
};
const SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS = 5_000;
function enqueueApprovalRequest(host: GatewayHost, entry: ExecApprovalRequest | null) {
if (!entry) {
return;
@@ -173,6 +176,29 @@ function isChatTurnSessionChangedPayload(payload: unknown): boolean {
);
}
function clearSessionsChangedReloadTimer(host: GatewayHost) {
if (host.sessionsChangedReloadTimer == null) {
return;
}
globalThis.clearTimeout(host.sessionsChangedReloadTimer);
host.sessionsChangedReloadTimer = null;
}
function shouldRunDeferredSessionsReload(host: GatewayHost): boolean {
return host.connected && Boolean(host.client) && host.tab !== "chat";
}
function scheduleSessionsChangedReload(host: GatewayHost) {
clearSessionsChangedReloadTimer(host);
host.sessionsChangedReloadTimer = globalThis.setTimeout(() => {
host.sessionsChangedReloadTimer = null;
if (!shouldRunDeferredSessionsReload(host)) {
return;
}
void loadSessions(host as unknown as SessionsState);
}, SESSIONS_CHANGED_RELOAD_DEBOUNCE_MS);
}
type ConnectGatewayOptions = {
reason?: "initial" | "seq-gap";
};
@@ -462,6 +488,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
const reconnectReason = options?.reason ?? "initial";
shutdownHost.pendingShutdownMessage = null;
shutdownHost.resumeChatQueueAfterReconnect = false;
clearSessionsChangedReloadTimer(host);
host.lastError = null;
host.lastErrorCode = null;
host.hello = null;
@@ -543,8 +570,6 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
}
void loadHealthState(host as unknown as HealthState);
void loadNodes(host as unknown as NodesState, { quiet: true });
void loadDevices(host as unknown as DevicesState, { quiet: true });
void loadAgentsThenRefreshActiveTab(host);
// Re-run push reconciliation now that the gateway client is available.
void host.reconcileWebPushState?.();
@@ -555,6 +580,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
return;
}
host.connected = false;
clearSessionsChangedReloadTimer(host);
// Code 1012 = Service Restart (expected during config saves, don't show as error)
host.lastErrorCode =
resolveGatewayErrorDetailCode(error) ??
@@ -642,6 +668,7 @@ function handleTerminalChatEvent(
void loadSessions(host as unknown as SessionsState, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
agentId: resolveChatEventSessionListAgentId(host, payload),
limit: CHAT_SESSIONS_REFRESH_LIMIT,
});
}
}
@@ -832,7 +859,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
if (result.applied || isChatTurnSessionChangedPayload(evt.payload)) {
return;
}
void loadSessions(host as unknown as SessionsState);
scheduleSessionsChangedReload(host);
return;
}

View File

@@ -40,6 +40,9 @@ vi.mock("./app-scroll.ts", () => ({
}));
import { handleConnected } from "./app-lifecycle.ts";
import { startNodesPolling } from "./app-polling.ts";
const startNodesPollingMock = vi.mocked(startNodesPolling);
function createDeferred() {
let resolve: (() => void) | undefined;
@@ -82,6 +85,7 @@ describe("handleConnected", () => {
applySettingsFromUrlMock.mockReset();
connectGatewayMock.mockReset();
loadBootstrapMock.mockReset();
startNodesPollingMock.mockReset();
vi.stubGlobal("window", {
addEventListener: vi.fn(),
});
@@ -129,4 +133,17 @@ describe("handleConnected", () => {
loadBootstrapMock.mock.invocationCallOrder[0],
);
});
it("starts Nodes polling only when the Nodes tab is active on connect", () => {
loadBootstrapMock.mockResolvedValue(undefined);
const chatHost = createHost();
handleConnected(chatHost as never);
expect(startNodesPollingMock).not.toHaveBeenCalled();
const nodesHost = createHost();
nodesHost.tab = "nodes";
handleConnected(nodesHost as never);
expect(startNodesPollingMock).toHaveBeenCalledWith(nodesHost);
});
});

View File

@@ -1,5 +1,5 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { handleDisconnected } from "./app-lifecycle.ts";
function createHost() {
@@ -22,12 +22,17 @@ function createHost() {
logsAutoFollow: false,
logsAtBottom: true,
logsEntries: [],
sessionsChangedReloadTimer: null as number | ReturnType<typeof globalThis.setTimeout> | null,
popStateHandler: vi.fn(),
topbarObserver: { disconnect: vi.fn() } as unknown as ResizeObserver,
};
}
describe("handleDisconnected", () => {
afterEach(() => {
vi.useRealTimers();
});
it("stops and clears gateway client on teardown", () => {
vi.stubGlobal("window", {
removeEventListener: vi.fn(),
@@ -49,4 +54,21 @@ describe("handleDisconnected", () => {
removeSpy.mockRestore();
vi.unstubAllGlobals();
});
it("clears pending session reload timers on teardown", () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
removeEventListener: vi.fn(),
});
const host = createHost();
const pendingReload = vi.fn();
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
handleDisconnected(host as unknown as Parameters<typeof handleDisconnected>[0]);
expect(host.sessionsChangedReloadTimer).toBeNull();
vi.advanceTimersByTime(1_000);
expect(pendingReload).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});

View File

@@ -52,6 +52,7 @@ type LifecycleHost = {
chatScrollFrame?: number | null;
chatScrollTimeout?: number | null;
logsScrollFrame?: number | null;
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
controlUiTabPaintSeq?: number;
controlUiResponsivenessObserver?: { disconnect: () => void } | null;
popStateHandler: () => void;
@@ -72,7 +73,9 @@ export function handleConnected(host: LifecycleHost) {
}
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
});
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "nodes") {
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
}
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
}
@@ -100,6 +103,14 @@ function clearHostTimeout(timeout: number | null | undefined) {
}
}
function clearHostGlobalTimeout(
timeout: number | ReturnType<typeof globalThis.setTimeout> | null | undefined,
) {
if (timeout != null) {
globalThis.clearTimeout(timeout);
}
}
export function handleDisconnected(host: LifecycleHost) {
host.connectGeneration += 1;
host.controlUiTabPaintSeq = (host.controlUiTabPaintSeq ?? 0) + 1;
@@ -113,6 +124,8 @@ export function handleDisconnected(host: LifecycleHost) {
host.logsScrollFrame = null;
clearHostTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
clearHostGlobalTimeout(host.sessionsChangedReloadTimer);
host.sessionsChangedReloadTimer = null;
host.realtimeTalkSession?.stop();
host.realtimeTalkSession = null;
host.realtimeTalkActive = false;

View File

@@ -0,0 +1,62 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
const loadNodesMock = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./controllers/debug.ts", () => ({
loadDebug: vi.fn(async () => undefined),
}));
vi.mock("./controllers/logs.ts", () => ({
loadLogs: vi.fn(async () => undefined),
}));
vi.mock("./controllers/nodes.ts", () => ({
loadNodes: loadNodesMock,
}));
const { NODES_ACTIVE_POLL_INTERVAL_MS, startNodesPolling, stopNodesPolling } =
await import("./app-polling.ts");
function createHost() {
return {
client: {},
connected: true,
nodesPollInterval: null,
logsPollInterval: null,
debugPollInterval: null,
tab: "overview",
};
}
describe("startNodesPolling", () => {
let testHost: ReturnType<typeof createHost> | null = null;
afterEach(() => {
if (testHost) {
stopNodesPolling(testHost as never);
testHost = null;
}
vi.useRealTimers();
vi.unstubAllGlobals();
loadNodesMock.mockReset();
});
it("does not poll nodes while another tab is active", () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearInterval: globalThis.clearInterval,
setInterval: globalThis.setInterval,
});
const host = createHost();
testHost = host;
startNodesPolling(host as never);
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
expect(loadNodesMock).not.toHaveBeenCalled();
host.tab = "nodes";
vi.advanceTimersByTime(NODES_ACTIVE_POLL_INTERVAL_MS);
expect(loadNodesMock).toHaveBeenCalledWith(host, { quiet: true });
stopNodesPolling(host as never);
});
});

View File

@@ -12,14 +12,18 @@ type PollingHost = {
tab: string;
};
export const NODES_ACTIVE_POLL_INTERVAL_MS = 30_000;
export function startNodesPolling(host: PollingHost) {
if (host.nodesPollInterval != null) {
return;
}
host.nodesPollInterval = window.setInterval(
() => void loadNodes(host as unknown as NodesState, { quiet: true }),
5000,
);
host.nodesPollInterval = window.setInterval(() => {
if (host.tab !== "nodes") {
return;
}
void loadNodes(host as unknown as NodesState, { quiet: true });
}, NODES_ACTIVE_POLL_INTERVAL_MS);
}
export function stopNodesPolling(host: PollingHost) {

View File

@@ -17,6 +17,8 @@ const {
}));
vi.mock("./app-chat.ts", () => ({
CHAT_SESSIONS_ACTIVE_MINUTES: 120,
CHAT_SESSIONS_REFRESH_LIMIT: 100,
refreshChat: refreshChatMock,
refreshChatAvatar: refreshChatAvatarMock,
}));
@@ -734,8 +736,8 @@ describe("createChatSession", () => {
emitCommandHooks: true,
},
{
activeMinutes: 0,
limit: 0,
activeMinutes: 120,
limit: 100,
includeGlobal: true,
includeUnknown: true,
showArchived: false,
@@ -936,8 +938,8 @@ describe("switchChatSession", () => {
});
expect(loadChatHistoryMock).toHaveBeenCalledWith(state);
expect(loadSessionsMock).toHaveBeenCalledWith(state, {
activeMinutes: 0,
limit: 0,
activeMinutes: 120,
limit: 100,
includeGlobal: true,
includeUnknown: true,
showArchived: false,

View File

@@ -1,6 +1,11 @@
import { html, nothing } from "lit";
import { t } from "../i18n/index.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
import {
CHAT_SESSIONS_ACTIVE_MINUTES,
CHAT_SESSIONS_REFRESH_LIMIT,
refreshChat,
refreshChatAvatar,
} from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import {
@@ -642,8 +647,8 @@ export async function createChatSession(state: AppViewState) {
emitCommandHooks: parentSessionKey !== undefined ? true : undefined,
},
{
activeMinutes: 0,
limit: 0,
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
limit: CHAT_SESSIONS_REFRESH_LIMIT,
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,
@@ -674,8 +679,8 @@ export async function createChatSession(state: AppViewState) {
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
limit: CHAT_SESSIONS_REFRESH_LIMIT,
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,

View File

@@ -2,7 +2,12 @@ import { html, nothing } from "lit";
import { styleMap } from "lit/directives/style-map.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { hasAbortableSessionRun, refreshChat } from "./app-chat.ts";
import {
CHAT_SESSIONS_ACTIVE_MINUTES,
CHAT_SESSIONS_REFRESH_LIMIT,
hasAbortableSessionRun,
refreshChat,
} from "./app-chat.ts";
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
import {
@@ -2516,8 +2521,8 @@ export function renderApp(state: AppViewState) {
state.sessionsExpandedCheckpointKey = state.sessionKey;
state.setTab("sessions" as import("./navigation.ts").Tab);
void loadSessions(state, {
activeMinutes: 0,
limit: 0,
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
limit: CHAT_SESSIONS_REFRESH_LIMIT,
includeGlobal: true,
includeUnknown: true,
});

View File

@@ -1,5 +1,5 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type CronRunsLoadStatus = "ok" | "error" | "skipped";
@@ -55,11 +55,25 @@ const mocks = vi.hoisted(() => ({
loadSessionsMock: vi.fn(async () => {}),
loadSkillsMock: vi.fn(async () => {}),
loadUsageMock: vi.fn(async () => {}),
startDebugPollingMock: vi.fn(),
startLogsPollingMock: vi.fn(),
startNodesPollingMock: vi.fn(),
stopDebugPollingMock: vi.fn(),
stopLogsPollingMock: vi.fn(),
stopNodesPollingMock: vi.fn(),
}));
vi.mock("./app-chat.ts", () => ({
refreshChat: mocks.refreshChatMock,
}));
vi.mock("./app-polling.ts", () => ({
startDebugPolling: mocks.startDebugPollingMock,
startLogsPolling: mocks.startLogsPollingMock,
startNodesPolling: mocks.startNodesPollingMock,
stopDebugPolling: mocks.stopDebugPollingMock,
stopLogsPolling: mocks.stopLogsPollingMock,
stopNodesPolling: mocks.stopNodesPollingMock,
}));
vi.mock("./app-scroll.ts", () => ({
scheduleChatScroll: mocks.scheduleChatScrollMock,
scheduleLogsScroll: mocks.scheduleLogsScrollMock,
@@ -120,7 +134,7 @@ vi.mock("./controllers/usage.ts", () => ({
loadUsage: mocks.loadUsageMock,
}));
import { refreshActiveTab, setTab } from "./app-settings.ts";
import { loadChannelsTab, refreshActiveTab, setTab } from "./app-settings.ts";
function createHost() {
return {
@@ -141,6 +155,7 @@ function createHost() {
updateComplete: Promise.resolve(),
cronRunsScope: "all",
cronRunsJobId: null as string | null,
sessionsChangedReloadTimer: null as number | ReturnType<typeof globalThis.setTimeout> | null,
sessionKey: "main",
settings: {},
basePath: "",
@@ -184,6 +199,10 @@ describe("refreshActiveTab", () => {
}
});
afterEach(() => {
vi.useRealTimers();
});
const expectCommonAgentsTabRefresh = (host: ReturnType<typeof createHost>) => {
expect(mocks.loadAgentsMock).toHaveBeenCalledOnce();
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
@@ -239,6 +258,16 @@ describe("refreshActiveTab", () => {
expect(mocks.loadAgentSkillsMock).not.toHaveBeenCalled();
});
it("loads the Channels tab without automatic live probes", async () => {
const host = createHost();
await loadChannelsTab(host as never);
expect(mocks.loadChannelsMock).toHaveBeenCalledWith(host, false);
expect(mocks.loadConfigSchemaMock).toHaveBeenCalledWith(host);
expect(mocks.loadConfigMock).toHaveBeenCalledWith(host);
});
it("refreshes logs tab by resetting bottom-follow and scheduling scroll", async () => {
const host = createHost();
host.tab = "logs";
@@ -269,6 +298,26 @@ describe("refreshActiveTab", () => {
sessions.resolve();
});
it("starts node polling on Nodes tab entry and clears pending session reloads on tab changes", () => {
vi.useFakeTimers();
const host = createHost();
host.tab = "overview";
const pendingReload = vi.fn();
host.sessionsChangedReloadTimer = globalThis.setTimeout(pendingReload, 1_000);
setTab(host as never, "nodes");
expect(host.sessionsChangedReloadTimer).toBeNull();
expect(mocks.startNodesPollingMock).toHaveBeenCalledWith(host);
expect(mocks.stopLogsPollingMock).toHaveBeenCalledWith(host);
expect(mocks.stopDebugPollingMock).toHaveBeenCalledWith(host);
vi.advanceTimersByTime(1_000);
expect(pendingReload).not.toHaveBeenCalled();
setTab(host as never, "sessions");
expect(mocks.stopNodesPollingMock).toHaveBeenCalledWith(host);
});
it("does not wait for secondary overview refreshes before resolving", async () => {
const host = createHost();
host.tab = "overview";
@@ -304,31 +353,24 @@ describe("refreshActiveTab", () => {
});
});
it("renders channels from the cheap snapshot before starting slow probes", async () => {
it("renders channels from the cheap snapshot without waiting for config schema", async () => {
const host = createHost();
host.tab = "channels";
const schema = createDeferred();
const channelProbe = createDeferred();
mocks.loadConfigSchemaMock.mockReturnValueOnce(schema.promise);
mocks.loadChannelsMock.mockImplementation(async (_host, probe) => {
if (probe) {
await channelProbe.promise;
}
});
const refresh = refreshActiveTab(host as never);
const outcome = await raceWithNextMacrotask(refresh);
expect(outcome).toBe("resolved");
expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false, true]);
expect(mocks.loadChannelsMock.mock.calls.map(([, probe]) => probe)).toEqual([false]);
expect(mocks.loadConfigMock).toHaveBeenCalledOnce();
expect(host.requestUpdate).not.toHaveBeenCalled();
schema.resolve();
channelProbe.resolve();
await vi.waitFor(() => {
expect(host.requestUpdate).toHaveBeenCalledTimes(2);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
});

View File

@@ -3,7 +3,9 @@ import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts";
import {
startLogsPolling,
startNodesPolling,
stopLogsPolling,
stopNodesPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling.ts";
@@ -106,6 +108,7 @@ type SettingsHost = {
controlUiTabPaintSeq?: number;
controlUiOverviewRefreshSeq?: number;
controlUiCronRefreshSeq?: number;
sessionsChangedReloadTimer?: number | ReturnType<typeof globalThis.setTimeout> | null;
dreamingStatusLoading: boolean;
dreamingStatusError: string | null;
dreamingStatus: import("./controllers/dreaming.js").DreamingStatus | null;
@@ -549,6 +552,14 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
applyTabSelection(host, next, { refreshPolicy: "connected" });
}
function clearPendingSessionsChangedReload(host: SettingsHost) {
if (host.sessionsChangedReloadTimer == null) {
return;
}
globalThis.clearTimeout(host.sessionsChangedReloadTimer);
host.sessionsChangedReloadTimer = null;
}
function updateBrowserHistory(url: URL, replace: boolean) {
const history = typeof window === "undefined" ? undefined : window.history;
if (!history) {
@@ -569,6 +580,7 @@ function applyTabSelection(
host.tab = next;
if (prev !== next) {
scheduleControlUiTabVisibleTiming(host, prev, next);
clearPendingSessionsChangedReload(host);
}
// Cleanup chat module state when navigating away from chat
@@ -582,6 +594,9 @@ function applyTabSelection(
(next === "logs" ? startLogsPolling : stopLogsPolling)(
host as unknown as Parameters<typeof startLogsPolling>[0],
);
(next === "nodes" ? startNodesPolling : stopNodesPolling)(
host as unknown as Parameters<typeof startNodesPolling>[0],
);
(next === "debug" ? startDebugPolling : stopDebugPolling)(
host as unknown as Parameters<typeof startDebugPolling>[0],
);
@@ -839,7 +854,6 @@ export async function loadChannelsTab(host: SettingsHost) {
const app = host as unknown as SettingsAppHost;
void loadConfigSchema(app).finally(() => host.requestUpdate?.());
await Promise.all([loadChannels(app, false), loadConfig(app)]);
void loadChannels(app, true).finally(() => host.requestUpdate?.());
}
export async function loadCron(host: SettingsHost) {

View File

@@ -598,6 +598,7 @@ export class OpenClawApp extends LitElement {
nodesPollInterval: number | null = null;
logsPollInterval: number | null = null;
debugPollInterval: number | null = null;
sessionsChangedReloadTimer: number | ReturnType<typeof globalThis.setTimeout> | null = null;
logsScrollFrame: number | null = null;
controlUiResponsivenessObserver: { disconnect: () => void } | null = null;
toolStreamById = new Map<string, ToolStreamEntry>();

View File

@@ -1,5 +1,6 @@
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { CHAT_SESSIONS_ACTIVE_MINUTES, CHAT_SESSIONS_REFRESH_LIMIT } from "../app-chat.ts";
import type { AppViewState } from "../app-view-state.ts";
import { createChatModelOverride } from "../chat-model-ref.ts";
import {
@@ -138,8 +139,8 @@ function renderChatAgentSelect(
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
limit: CHAT_SESSIONS_REFRESH_LIMIT,
includeGlobal: true,
includeUnknown: true,
showArchived: state.sessionsShowArchived,

View File

@@ -15,6 +15,17 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function createChannelsSnapshot(label: string): ChannelsStatusSnapshot {
return {
ts: Date.now(),
channelOrder: ["test"],
channelLabels: { test: label },
channels: {},
channelAccounts: {},
channelDefaultAccountId: {},
};
}
function createState(): ChannelsState {
return {
client: {
@@ -70,6 +81,34 @@ describe("channels controller WhatsApp wait", () => {
});
describe("loadChannels", () => {
it("keeps a stale slow probe from replacing a newer non-probe snapshot", async () => {
const state = createState();
const request = vi.mocked(state.client!.request);
const slowProbe = createDeferred<ChannelsStatusSnapshot | null>();
const fastRuntime = createDeferred<ChannelsStatusSnapshot | null>();
request.mockImplementation(async (_method: string, params?: unknown) => {
if ((params as { probe?: boolean } | undefined)?.probe) {
return slowProbe.promise;
}
return fastRuntime.promise;
});
const probeLoad = loadChannels(state, true, { softTimeoutMs: 1 });
await probeLoad;
const runtimeLoad = loadChannels(state, false);
expect(request).toHaveBeenCalledTimes(2);
fastRuntime.resolve(createChannelsSnapshot("fresh"));
await runtimeLoad;
expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh");
slowProbe.resolve(createChannelsSnapshot("stale"));
await Promise.resolve();
expect(state.channelsSnapshot?.channelLabels.test).toBe("fresh");
expect(state.channelsLoading).toBe(false);
});
it("returns after a soft timeout while preserving the stale snapshot", async () => {
vi.useFakeTimers();
try {

View File

@@ -23,10 +23,13 @@ export async function loadChannels(
if (!state.client || !state.connected) {
return;
}
if (state.channelsLoading) {
if (state.channelsLoading && (!state.channelsLoadingProbe || probe)) {
return;
}
const refreshSeq = (state.channelsRefreshSeq ?? 0) + 1;
state.channelsRefreshSeq = refreshSeq;
state.channelsLoading = true;
state.channelsLoadingProbe = probe;
state.channelsError = null;
const refresh = (async () => {
try {
@@ -34,9 +37,15 @@ export async function loadChannels(
probe,
timeoutMs: 8000,
});
if (state.channelsRefreshSeq !== refreshSeq) {
return;
}
state.channelsSnapshot = res;
state.channelsLastSuccess = Date.now();
} catch (err) {
if (state.channelsRefreshSeq !== refreshSeq) {
return;
}
if (isMissingOperatorReadScopeError(err)) {
state.channelsSnapshot = null;
state.channelsError = formatMissingOperatorReadScopeMessage("channel status");
@@ -44,7 +53,10 @@ export async function loadChannels(
state.channelsError = String(err);
}
} finally {
state.channelsLoading = false;
if (state.channelsRefreshSeq === refreshSeq) {
state.channelsLoading = false;
state.channelsLoadingProbe = null;
}
}
})();

View File

@@ -5,6 +5,8 @@ export type ChannelsState = {
client: GatewayBrowserClient | null;
connected: boolean;
channelsLoading: boolean;
channelsLoadingProbe?: boolean | null;
channelsRefreshSeq?: number;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;

View File

@@ -401,6 +401,43 @@ describe("config view", () => {
expect(content.scrollLeft).toBe(0);
});
it("does not normalize off-scope schema sections for scoped config tabs", () => {
const offScopeSchema = { type: "object" } as Record<string, unknown>;
Object.defineProperty(offScopeSchema, "properties", {
get() {
throw new Error("off-scope schema was normalized");
},
});
const { container } = renderConfigView({
activeSection: "channels",
navRootLabel: "Communication",
includeSections: ["channels"],
schema: {
type: "object",
properties: {
channels: {
type: "object",
properties: {
telegram: { type: "string", title: "Telegram" },
},
},
models: offScopeSchema,
},
},
formValue: {
channels: { telegram: "enabled" },
models: {},
},
originalValue: {
channels: { telegram: "enabled" },
models: {},
},
});
expect(normalizedText(container)).toContain("Telegram");
});
it("renders and wires the search field controls", () => {
const container = document.createElement("div");
const onSearchChange = vi.fn();

View File

@@ -478,40 +478,23 @@ function scopeSchemaSections(
const include = params.include;
const exclude = params.exclude;
const nextProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(schema.properties)) {
for (const key of Object.keys(schema.properties)) {
if (include && include.size > 0 && !include.has(key)) {
continue;
}
if (exclude && exclude.size > 0 && exclude.has(key)) {
continue;
}
nextProps[key] = value;
nextProps[key] = schema.properties[key];
}
return { ...schema, properties: nextProps };
}
function scopeUnsupportedPaths(
unsupportedPaths: string[],
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): string[] {
const include = params.include;
const exclude = params.exclude;
if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) {
return unsupportedPaths;
function asConfigSchema(value: unknown): JsonSchema | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return unsupportedPaths.filter((entry) => {
if (entry === "<root>") {
return true;
}
const [top] = entry.split(".");
if (include && include.size > 0) {
return include.has(top);
}
if (exclude && exclude.size > 0) {
return !exclude.has(top);
}
return true;
});
return value as JsonSchema;
}
function resolveSectionMeta(
@@ -1177,11 +1160,8 @@ export function renderConfig(props: ConfigProps) {
const includeVirtualSections = props.includeVirtualSections ?? true;
const include = props.includeSections?.length ? new Set(props.includeSections) : null;
const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null;
const rawAnalysis = analyzeConfigSchema(props.schema);
const analysis = {
schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }),
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
};
const scopedSchema = scopeSchemaSections(asConfigSchema(props.schema), { include, exclude });
const analysis = analyzeConfigSchema(scopedSchema);
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
const rawAvailable = props.rawAvailable ?? true;
const formMode = showModeToggle && rawAvailable ? props.formMode : "form";