refactor(ui): adopt @lit/task across remaining page surfaces (#115274)

* refactor(ui): adopt @lit/task across remaining page surfaces

* refactor(ui): keep approval history pagination explicit

* fix(ui): preserve task refresh ordering
This commit is contained in:
Peter Steinberger
2026-07-28 12:44:34 -04:00
committed by GitHub
parent 7df5834511
commit c41ca078a6
27 changed files with 830 additions and 876 deletions
@@ -24,6 +24,27 @@ afterEach(() => {
});
describe("SessionPullRequestIndicatorsController", () => {
it("does not schedule a Task invalidation loop when no rows are eligible", async () => {
vi.useFakeTimers();
const host = new TestHost();
const controller = new SessionPullRequestIndicatorsController(host, {
getConnected: () => true,
getRows: () => [],
getSelectedAgentId: () => "main",
getSnapshot: () =>
({
client: {} as GatewayBrowserClient,
hello: { features: { methods: ["controlUi.sessionPullRequests"] } },
}) as ApplicationGatewaySnapshot,
});
controller.hostConnected();
controller.hostUpdated();
await vi.advanceTimersByTimeAsync(0);
expect(host.requestUpdate).not.toHaveBeenCalled();
});
it("refreshes visible PR state and keeps the last value while rate limited", async () => {
vi.useFakeTimers();
const host = new TestHost();
@@ -1,3 +1,4 @@
import { initialState, Task, TaskStatus } from "@lit/task";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { ApplicationGatewaySnapshot } from "../app/context.ts";
@@ -29,10 +30,8 @@ export class SessionPullRequestIndicatorsController implements ReactiveControlle
private client: GatewayBrowserClient | null = null;
private agentId: string | null = null;
private connected = false;
private epoch = 0;
private eligibleSignature = "";
private refresh: Promise<void> | null = null;
private refreshAgain = false;
private readonly refreshTask: Task;
private refreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
private refreshScheduled = false;
@@ -41,6 +40,62 @@ export class SessionPullRequestIndicatorsController implements ReactiveControlle
private readonly options: SessionPullRequestIndicatorsOptions,
) {
host.addController(this);
this.refreshTask = new Task(host, {
autoRun: false,
// Rows are represented by a deterministic primitive so Lit can shallow-compare args.
args: () => [null as GatewayBrowserClient | null, "", ""] as const,
task: async ([client, selectedAgentId, signature], { signal }) => {
if (!client || !signature) {
return initialState;
}
const eligibleRows = this.options
.getRows()
.filter((session) => !session.isChild && session.worktreeId);
const currentSignature = JSON.stringify(
eligibleRows.map((session) => [session.key, session.worktreeId]),
);
if (currentSignature !== signature) {
return initialState;
}
const entries: Array<readonly [string, IndicatorEntry]> = [];
for (const session of eligibleRows) {
if (signal.aborted) {
break;
}
try {
const state = await fetchSessionPullRequestIndicatorState({
client,
pullRequestsAvailable: true,
sessionKey: session.key,
agentId: parseAgentSessionKey(session.key)?.agentId ?? selectedAgentId,
});
if (state !== null && session.worktreeId) {
entries.push([session.key, { state, worktreeId: session.worktreeId }]);
}
} catch {
// Optional metadata: preserve the last-known indicator and retry next poll.
}
}
return { client, entries };
},
onComplete: ({ client, entries }) => {
if (this.options.getSnapshot()?.client !== client) {
return;
}
let changed = false;
for (const [sessionKey, entry] of entries) {
const current = this.states.get(sessionKey);
if (current?.state !== entry.state || current.worktreeId !== entry.worktreeId) {
this.states.set(sessionKey, entry);
changed = true;
}
}
if (changed) {
this.host.requestUpdate();
}
this.scheduleRefreshTimer();
},
});
}
hostConnected(): void {
@@ -95,9 +150,11 @@ export class SessionPullRequestIndicatorsController implements ReactiveControlle
}
private reset(requestUpdate: boolean): void {
this.epoch += 1;
const shouldInvalidate = this.eligibleSignature !== "";
this.eligibleSignature = "";
this.refreshAgain = false;
if (shouldInvalidate) {
void this.refreshTask.run([null, "", ""]);
}
this.clearRefreshTimer();
if (this.states.size === 0) {
return;
@@ -140,8 +197,12 @@ export class SessionPullRequestIndicatorsController implements ReactiveControlle
this.host.requestUpdate();
}
if (eligibleRows.length === 0) {
const shouldInvalidate = this.eligibleSignature !== "";
this.eligibleSignature = "";
this.clearRefreshTimer();
if (shouldInvalidate) {
void this.refreshTask.run([null, "", ""]);
}
return;
}
@@ -149,93 +210,13 @@ export class SessionPullRequestIndicatorsController implements ReactiveControlle
eligibleRows.map((session) => [session.key, session.worktreeId]),
);
if (!force && signature === this.eligibleSignature) {
if (this.refresh === null) {
if (this.refreshTask.status !== TaskStatus.PENDING) {
this.scheduleRefreshTimer();
}
return;
}
this.eligibleSignature = signature;
if (this.refresh) {
this.refreshAgain = true;
return;
}
this.clearRefreshTimer();
const epoch = this.epoch;
const refresh = this.load({
client: snapshot.client,
selectedAgentId,
eligibleRows,
epoch,
signature,
});
this.refresh = refresh;
void refresh.finally(() => {
if (this.refresh !== refresh) {
return;
}
this.refresh = null;
if (!this.connected) {
return;
}
if (this.refreshAgain) {
this.refreshAgain = false;
this.refreshVisible(true);
return;
}
if (epoch === this.epoch && signature === this.eligibleSignature) {
this.scheduleRefreshTimer();
}
});
}
private async load(params: {
client: GatewayBrowserClient;
selectedAgentId: string;
eligibleRows: readonly SidebarRecentSession[];
epoch: number;
signature: string;
}): Promise<void> {
for (const session of params.eligibleRows) {
if (!this.isCurrent(params)) {
return;
}
try {
const indicatorState = await fetchSessionPullRequestIndicatorState({
client: params.client,
pullRequestsAvailable: true,
sessionKey: session.key,
agentId: parseAgentSessionKey(session.key)?.agentId ?? params.selectedAgentId,
});
if (indicatorState === null || !this.isCurrent(params)) {
continue;
}
const worktreeId = session.worktreeId;
const current = this.states.get(session.key);
if (
worktreeId &&
(current?.state !== indicatorState || current.worktreeId !== worktreeId)
) {
this.states.set(session.key, { state: indicatorState, worktreeId });
this.host.requestUpdate();
}
} catch {
// Optional metadata: preserve the last-known indicator and retry next poll.
}
}
}
private isCurrent(params: {
client: GatewayBrowserClient;
epoch: number;
signature: string;
}): boolean {
return (
this.connected &&
this.options.getConnected() &&
params.epoch === this.epoch &&
params.signature === this.eligibleSignature &&
this.options.getSnapshot()?.client === params.client
);
void this.refreshTask.run([snapshot.client, selectedAgentId, signature]);
}
}
@@ -95,12 +95,16 @@ describe("openclaw-github-link-hovercard-provider", () => {
expect(card?.textContent).toContain("5m ago");
expect(anchor.href).toBe(href);
expect(anchor.getAttribute("aria-describedby")).toBe(card?.id);
expect(request).toHaveBeenCalledWith("controlUi.githubPreview", {
kind: "pull",
number: 99816,
owner: "openclaw",
repo: "openclaw",
});
expect(request).toHaveBeenCalledWith(
"controlUi.githubPreview",
{
kind: "pull",
number: 99816,
owner: "openclaw",
repo: "openclaw",
},
{ signal: expect.any(AbortSignal) },
);
leave(anchor);
expect(document.querySelector(".github-link-hovercard")).toBeNull();
+40 -24
View File
@@ -1,4 +1,6 @@
import { initialState, Task } from "@lit/task";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { ReactiveElement } from "lit";
import type { ControlUiGitHubPreview } from "../../../src/gateway/control-ui-contract.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { i18n, t } from "../i18n/index.ts";
@@ -293,7 +295,7 @@ function anchorFromEvent(event: Event): HTMLAnchorElement | null {
return null;
}
export class GitHubLinkHovercardProvider extends HTMLElement {
export class GitHubLinkHovercardProvider extends ReactiveElement {
client: GatewayBrowserClient | null = null;
private readonly cache = new Map<string, CacheEntry>();
@@ -306,8 +308,30 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
private pointerInside = false;
private renderedPreview: GitHubPreview | null = null;
private renderedUnavailable = false;
private requestVersion = 0;
private stopI18n: (() => void) | null = null;
private readonly previewTask = new Task(this, {
autoRun: false,
args: () => [this.activeTarget] as const,
task: ([target], { signal }) => (target ? this.loadPreview(target, signal) : initialState),
onComplete: (preview) => {
const card = this.card;
if (!card) {
return;
}
this.renderedPreview = preview;
renderPreview(card, preview);
this.positionCard();
},
onError: () => {
const card = this.card;
if (!card) {
return;
}
this.renderedUnavailable = true;
renderUnavailable(card);
this.positionCard();
},
});
private readonly activeAnchorObserver = new MutationObserver(() => {
const anchor = this.activeAnchor;
// The card is portaled outside the routed tree, whose replacement can remove
@@ -317,7 +341,12 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
}
});
connectedCallback(): void {
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "contents";
this.addEventListener("pointerover", this.handlePointerOver);
this.addEventListener("pointerout", this.handlePointerOut);
@@ -328,7 +357,7 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
this.stopI18n ??= i18n.subscribe(this.handleLocaleChange);
}
disconnectedCallback(): void {
override disconnectedCallback(): void {
this.removeEventListener("pointerover", this.handlePointerOver);
this.removeEventListener("pointerout", this.handlePointerOut);
this.removeEventListener("focusin", this.handleFocusIn);
@@ -338,6 +367,7 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
this.stopI18n?.();
this.stopI18n = null;
this.close();
super.disconnectedCallback();
}
private readonly handleLocaleChange = () => {
@@ -427,15 +457,14 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
this.activeAnchorObserver.observe(this, { childList: true, subtree: true });
this.openTimer = window.setTimeout(() => {
this.openTimer = null;
void this.show(anchor, target);
this.show(anchor, target);
}, delay);
}
private async show(anchor: HTMLAnchorElement, target: GitHubLinkTarget): Promise<void> {
private show(anchor: HTMLAnchorElement, target: GitHubLinkTarget): void {
if (this.activeAnchor !== anchor || this.activeTarget?.href !== target.href) {
return;
}
const version = ++this.requestVersion;
const card = document.createElement("div");
nextHovercardId += 1;
card.id = `openclaw-github-hovercard-${nextHovercardId}`;
@@ -455,24 +484,10 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
this.listenForViewportChanges();
this.positionCard();
try {
const preview = await this.loadPreview(target);
if (version !== this.requestVersion || card !== this.card) {
return;
}
this.renderedPreview = preview;
renderPreview(card, preview);
} catch {
if (version !== this.requestVersion || card !== this.card) {
return;
}
this.renderedUnavailable = true;
renderUnavailable(card);
}
this.positionCard();
void this.previewTask.run([target]);
}
private loadPreview(target: GitHubLinkTarget): Promise<GitHubPreview> {
private loadPreview(target: GitHubLinkTarget, signal: AbortSignal): Promise<GitHubPreview> {
const key = `${target.kind}:${target.owner.toLowerCase()}/${target.repo.toLowerCase()}#${target.number}`;
const now = Date.now();
const cached = this.cache.get(key);
@@ -497,6 +512,7 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
owner: target.owner,
repo: target.repo,
},
{ signal },
);
return parsePreviewResponse(target, response);
};
@@ -527,7 +543,7 @@ export class GitHubLinkHovercardProvider extends HTMLElement {
this.openTimer = null;
}
this.activeAnchorObserver.disconnect();
this.requestVersion += 1;
void this.previewTask.run([null]);
if (this.activeAnchor) {
if (this.describedBy === null) {
this.activeAnchor.removeAttribute("aria-describedby");
+41 -40
View File
@@ -3,6 +3,7 @@
// list — alerts surface where the user already is instead of on a dashboard
// they have to visit.
import { consume } from "@lit/context";
import { initialState, Task } from "@lit/task";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
@@ -48,11 +49,43 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
@property({ attribute: false }) onOpenApprovals?: () => void;
private loadedClient: GatewayBrowserClient | null = null;
private loadGeneration = 0;
private loadedGateway: ApplicationContext["gateway"] | null = null;
private loadedAtMs = 0;
private dismissedScope: string | null = null;
private idleRefreshTimer: ReturnType<typeof globalThis.setInterval> | null = null;
private readonly loadTask = new Task(this, {
autoRun: false,
// Gateway identity matters when a replacement source reuses the same client object.
args: () =>
[null as ApplicationContext["gateway"] | null, null as GatewayBrowserClient | null] as const,
task: async ([gateway, client], { signal }) => {
if (!gateway || !client) {
return initialState;
}
const cron = createInitialCronState({ client, connected: true });
await Promise.allSettled([
loadCronJobsPage(cron).then(() => {
if (!signal.aborted) {
this.cronJobs = cron.cronJobs;
}
}),
loadModelAuthStatus(client, { signal })
.catch(() => null)
.then((modelAuthStatus) => {
if (!signal.aborted) {
this.modelAuthStatus = modelAuthStatus;
}
}),
]);
return true;
},
onComplete: () => {
this.loadedAtMs = Date.now();
this.pruneAfterRefresh();
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
@@ -103,8 +136,9 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
this.idleRefreshTimer = null;
}
this.subscriptions.clear();
this.loadGeneration += 1;
void this.loadTask.run([null, null]);
this.loadedClient = null;
this.loadedGateway = null;
super.disconnectedCallback();
}
@@ -116,52 +150,19 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
this.dismissed = loadDismissals(gatewayUrl);
}
if (snapshot.phase !== "connected" || !snapshot.client) {
this.loadGeneration += 1;
void this.loadTask.run([null, null]);
this.loadedClient = null;
this.loadedGateway = null;
this.cronJobs = [];
this.modelAuthStatus = null;
return;
}
if (snapshot.client === this.loadedClient) {
if (gateway === this.loadedGateway && snapshot.client === this.loadedClient) {
return;
}
this.loadedGateway = gateway;
this.loadedClient = snapshot.client;
// Stale refreshes reuse the same client, so identity alone cannot retire
// an older completion once the replacement load starts.
const generation = ++this.loadGeneration;
void this.load(gateway, snapshot.client, generation);
}
private async load(
gateway: ApplicationContext["gateway"],
client: GatewayBrowserClient,
generation: number,
) {
const isCurrent = () =>
this.isConnected &&
this.loadGeneration === generation &&
this.loadedClient === client &&
gateway.snapshot.client === client &&
gateway.snapshot.phase === "connected";
const cron = createInitialCronState({ client, connected: true });
await Promise.allSettled([
loadCronJobsPage(cron).then(() => {
if (isCurrent()) {
this.cronJobs = cron.cronJobs;
}
}),
loadModelAuthStatus(client, {})
.catch(() => null)
.then((result) => {
if (isCurrent()) {
this.modelAuthStatus = result;
}
}),
]);
if (isCurrent()) {
this.loadedAtMs = Date.now();
this.pruneAfterRefresh();
}
void this.loadTask.run([gateway, snapshot.client]);
}
// Re-arm stale snoozes only right after this tab's own data refresh: fresh
+16 -14
View File
@@ -4,6 +4,7 @@
// tabs. Each tab hosts one libterminal Ghostty controller wired to a gateway PTY
// session. The browser runtime is dynamically imported on first open so it
// never weighs down the initial Control UI bundle.
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
@@ -70,10 +71,19 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
@state() terminalPanelErrorText: string | null = null;
@state() private sessionPickerOpen = false;
@state() private sessionPickerLoading = false;
@state() private pickerSessions: TerminalSessionInfo[] = [];
private sessionPickerRefreshGeneration = 0;
private readonly sessionPickerTask = new Task(this, {
autoRun: false,
// The controller reads the host client; carrying its identity retires stale picker loads.
args: () => [this.available ? this.client : null] as const,
task: ([client]) => (client ? this.terminalSessions.listSessions() : initialState),
onComplete: (sessions) => {
if (sessions !== null) {
this.pickerSessions = sessions;
}
},
});
readonly terminalPanelUploadController = new TerminalPanelUploadController({
activeTab: () =>
this.terminalSessions.tabs.find(
@@ -227,15 +237,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
}
}
private async refreshSessionPicker(): Promise<void> {
const refreshGeneration = ++this.sessionPickerRefreshGeneration;
this.sessionPickerLoading = true;
const sessions = await this.terminalSessions.listSessions();
if (refreshGeneration !== this.sessionPickerRefreshGeneration || sessions === null) {
return;
}
this.pickerSessions = sessions;
this.sessionPickerLoading = false;
private refreshSessionPicker(): Promise<void> {
return this.sessionPickerTask.run();
}
private async attachPickedSession(
@@ -253,8 +256,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
resetTerminalSessionPicker(): void {
this.sessionPickerOpen = false;
this.sessionPickerLoading = false;
this.sessionPickerRefreshGeneration += 1;
void this.sessionPickerTask.run([null]);
this.pickerSessions = [];
}
@@ -280,7 +282,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement {
activeTab?.status === "connecting";
const sessionPicker = renderTerminalSessionPicker({
open: this.sessionPickerOpen,
loading: this.sessionPickerLoading,
loading: this.sessionPickerTask.status === TaskStatus.PENDING,
sessions: this.pickerSessions,
currentSessionIds: new Set(
this.terminalSessions.tabs
+49 -54
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { property } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
@@ -19,6 +20,7 @@ type SharedWorkboardWidgetRuntime = {
host: WorkboardHost;
listeners: Set<(snapshot: ReturnType<typeof normalizeCardsPayload>) => void>;
loadPromise?: Promise<ReturnType<typeof normalizeCardsPayload>>;
snapshot?: ReturnType<typeof normalizeCardsPayload>;
};
type SharedWorkboardWidgetSubscription = {
@@ -77,6 +79,7 @@ function loadSharedWorkboardCards(
};
void load.then((snapshot) => {
releaseLoad();
runtime.snapshot = snapshot;
// A change can arrive after another widget started its snapshot request.
// Broadcast the eventual post-change reload so idle widgets cannot stay stale.
for (const listener of runtime.listeners) {
@@ -128,18 +131,41 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
protected cards: WorkboardCard[] = [];
protected statuses: readonly WorkboardStatus[] = [];
protected loading = false;
protected loaded = false;
protected error = "";
private loadAttempted = false;
private allCards: WorkboardCard[] = [];
private workboardHost: WorkboardHost = {};
private client: GatewayBrowserClient | null = null;
private sharedRuntime: SharedWorkboardWidgetRuntime | null = null;
private refreshGeneration = 0;
private refreshPromise: Promise<void> | null = null;
private refreshPending = false;
private readonly refreshTask = new Task(this, {
autoRun: false,
args: () => [this.client, this.sharedRuntime, false as boolean, false as boolean] as const,
task: async ([client, runtime, force, refreshAfterInflight]) => {
if (!client || !runtime) {
return initialState;
}
if (!force && runtime.snapshot) {
return runtime.snapshot;
}
const pending = runtime.loadPromise;
try {
const snapshot = await loadSharedWorkboardCards(client, runtime);
return refreshAfterInflight && pending
? loadSharedWorkboardCards(client, runtime)
: snapshot;
} catch (error) {
if (!refreshAfterInflight || !pending) {
throw error;
}
return loadSharedWorkboardCards(client, runtime);
}
},
onError: (error) => {
this.error = error instanceof Error ? error.message : String(error);
this.requestRender();
},
});
private readonly applySharedSnapshot = (
snapshot: ReturnType<typeof normalizeCardsPayload>,
): void => {
@@ -172,7 +198,7 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
}
override updated(): void {
if (!this.loadAttempted && !this.loading) {
if (!this.loaded && this.refreshTask.status === TaskStatus.INITIAL) {
void this.refresh();
}
}
@@ -181,17 +207,13 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
if (this.client && this.sharedRuntime) {
releaseWorkboardWidgetRuntime(this.client, this.sharedRuntime, this.applySharedSnapshot);
}
this.refreshGeneration += 1;
this.refreshPromise = null;
this.refreshPending = false;
void this.refreshTask.run([null, null, false, false]);
this.client = null;
this.sharedRuntime = null;
this.allCards = [];
this.cards = [];
this.workboardHost = {};
this.loaded = false;
this.loadAttempted = false;
this.loading = false;
this.error = "";
this.subscriptions.clear();
super.disconnectedCallback();
@@ -211,6 +233,10 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
void this.refresh(true);
}
protected get loading(): boolean {
return this.refreshTask.status === TaskStatus.PENDING;
}
protected async moveCard(card: WorkboardCard, status: WorkboardStatus): Promise<void> {
const client = this.client;
if (!client || !this.canMutate || !isActiveWorkboardCard(card) || card.status === status) {
@@ -257,16 +283,17 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
this.workboardHost = this.sharedRuntime?.host ?? {};
this.allCards = [];
this.cards = [];
this.refreshGeneration += 1;
this.refreshPromise = null;
this.refreshPending = false;
void this.refreshTask.run([null, null, false, false]);
this.loaded = false;
this.loadAttempted = false;
this.loading = false;
this.error = "";
// A cached runtime still has another live same-client listener; the last
// disconnect deletes it, while shared change events keep its snapshot current.
if (this.sharedRuntime?.snapshot) {
this.applySharedSnapshot(this.sharedRuntime.snapshot);
}
this.requestRender();
if (nextClient) {
void this.refresh(true);
if (nextClient && !this.sharedRuntime?.snapshot) {
void this.refresh();
}
}
@@ -276,45 +303,13 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
if (!client || !sharedRuntime || (!force && this.loaded)) {
return;
}
if (this.refreshPromise) {
if (force) {
this.refreshPending = true;
}
return await this.refreshPromise;
const refreshAfterInflight = force && this.refreshTask.status === TaskStatus.PENDING;
if (!force && this.refreshTask.status === TaskStatus.PENDING) {
return;
}
const generation = ++this.refreshGeneration;
this.loadAttempted = true;
this.loading = true;
this.error = "";
this.requestRender();
const refresh = (async () => {
try {
await loadSharedWorkboardCards(client, sharedRuntime);
} catch (error) {
if (generation === this.refreshGeneration && client === this.client) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (generation === this.refreshGeneration) {
this.loading = false;
this.requestRender();
}
}
})();
this.refreshPromise = refresh;
try {
await refresh;
} finally {
if (this.refreshPromise === refresh) {
this.refreshPromise = null;
const shouldRefreshAgain =
this.refreshPending && generation === this.refreshGeneration && client === this.client;
this.refreshPending = false;
if (shouldRefreshAgain) {
await this.refresh(true);
}
}
}
await this.refreshTask.run([client, sharedRuntime, force, refreshAfterInflight]);
}
private syncFromHost(): void {
+5 -4
View File
@@ -10,12 +10,13 @@ type GatewayDiagnosticsSnapshot = {
export async function loadGatewayDiagnostics(
client: GatewayBrowserClient,
signal?: AbortSignal,
): Promise<GatewayDiagnosticsSnapshot> {
const [status, health, models, heartbeat] = await Promise.all([
client.request("status", {}),
client.request("health", {}),
client.request("models.list", {}),
client.request("last-heartbeat", {}),
client.request("status", {}, { signal }),
client.request("health", {}, { signal }),
client.request("models.list", {}, { signal }),
client.request("last-heartbeat", {}, { signal }),
]);
const modelPayload = models as { models?: unknown[] } | undefined;
return {
+7 -4
View File
@@ -31,13 +31,16 @@ export function isMonitoredAuthProvider(p: ModelAuthStatusProvider): boolean {
export async function loadModelAuthStatus(
client: GatewayBrowserClient,
opts?: { refresh?: boolean; agentId?: string },
opts?: { refresh?: boolean; agentId?: string; signal?: AbortSignal },
): Promise<ModelAuthStatusResult> {
const params = {
...(opts?.refresh ? { refresh: true } : {}),
...(opts?.agentId ? { agentId: opts.agentId } : {}),
};
return (
(await client.request<ModelAuthStatusResult>("models.authStatus", params)) ?? EMPTY_AUTH_STATUS
);
const result = opts?.signal
? await client.request<ModelAuthStatusResult>("models.authStatus", params, {
signal: opts.signal,
})
: await client.request<ModelAuthStatusResult>("models.authStatus", params);
return result ?? EMPTY_AUTH_STATUS;
}
+13 -75
View File
@@ -13,7 +13,6 @@ import {
reconcileSkillsAgentId,
saveSkillApiKey,
searchClawHub,
setClawHubSearchQuery,
setSkillsAgentId,
updateSkillEdit,
updateSkillEnabled,
@@ -548,50 +547,18 @@ describe("loadSkillCard", () => {
});
describe("searchClawHub", () => {
it("clears stale query state immediately when the input changes", () => {
const { state } = createState();
state.clawhubSearchLoading = true;
state.clawhubInstallMessage = { kind: "success", text: "Installed github" };
setClawHubSearchQuery(state, "github app");
expect(state.clawhubSearchQuery).toBe("github app");
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchError).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
expect(state.clawhubInstallMessage).toBeNull();
});
it("clears stale results when the query is emptied", async () => {
it("skips the RPC when the query is empty", async () => {
const { state, request } = createState();
await searchClawHub(state, " ");
await expect(searchClawHub(state.client!, " ")).resolves.toEqual([]);
expect(request).not.toHaveBeenCalled();
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchError).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
});
it("clears stale results as soon as a new search starts", async () => {
it("returns search results and forwards cancellation", async () => {
const { state, request } = createState();
type SearchResponse = { results: SkillsState["clawhubSearchResults"] };
let resolveRequest: (value: SearchResponse) => void = () => {
throw new Error("expected search request promise to be pending");
};
request.mockImplementation(
() =>
new Promise<SearchResponse>((resolve) => {
resolveRequest = resolve;
}),
);
const pending = searchClawHub(state, "github");
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchLoading).toBe(true);
resolveRequest({
const controller = new AbortController();
request.mockResolvedValue({
results: [
{
score: 0.95,
@@ -602,44 +569,15 @@ describe("searchClawHub", () => {
},
],
});
await pending;
expect(state.clawhubSearchResults?.[0]?.slug).toBe("github-new");
expect(state.clawhubSearchLoading).toBe(false);
});
it("ignores stale search responses after query changes", async () => {
const { state, request } = createState();
const queue = createDeferredRequestQueue(request);
const pending = searchClawHub(state, "github");
setClawHubSearchQuery(state, "gitlab");
queue.resolveNext({
results: [{ score: 1, slug: "github", displayName: "GitHub" }],
});
await pending;
expect(state.clawhubSearchQuery).toBe("gitlab");
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchError).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
});
it("ignores a same-client search response from an older connection epoch", async () => {
const { state, request } = createState();
const queue = createDeferredRequestQueue(request);
const pending = searchClawHub(state, "github");
state.connected = false;
state.skillsAgentRevision++;
state.clawhubSearchLoading = false;
state.connected = true;
queue.resolveNext({
results: [{ score: 1, slug: "stale", displayName: "Stale" }],
});
await pending;
expect(state.clawhubSearchResults).toBeNull();
expect(state.clawhubSearchLoading).toBe(false);
await expect(searchClawHub(state.client!, "github", controller.signal)).resolves.toEqual([
expect.objectContaining({ slug: "github-new" }),
]);
expect(request).toHaveBeenCalledWith(
"skills.search",
{ query: "github", limit: 20 },
{ signal: controller.signal },
);
});
});
+11 -43
View File
@@ -259,14 +259,6 @@ async function runStaleAwareRequest<T>(
onFinally();
}
export function setClawHubSearchQuery(state: SkillsState, query: string) {
state.clawhubSearchQuery = query;
state.clawhubInstallMessage = null;
state.clawhubSearchResults = null;
state.clawhubSearchError = null;
state.clawhubSearchLoading = false;
}
export function setSkillsAgentId(state: SkillsState, agentId: string | null) {
const nextAgentId = agentId?.trim() || null;
if (state.skillsAgentId === nextAgentId) {
@@ -610,44 +602,20 @@ export async function installSkill(
});
}
export async function searchClawHub(state: SkillsState, query: string) {
if (!state.client || !state.connected) {
return;
}
export async function searchClawHub(
client: GatewayBrowserClient,
query: string,
signal?: AbortSignal,
): Promise<ClawHubSearchResult[]> {
if (!query.trim()) {
state.clawhubSearchResults = null;
state.clawhubSearchError = null;
state.clawhubSearchLoading = false;
return;
return [];
}
const client = state.client;
const agentScope = captureSkillsAgentScope(state);
// Clear stale entries as soon as a new search begins so the UI cannot act on
// results that no longer match the current query while the next request is in flight.
state.clawhubSearchResults = null;
state.clawhubSearchLoading = true;
state.clawhubSearchError = null;
await runStaleAwareRequest(
() =>
state.connected &&
state.client === client &&
query === state.clawhubSearchQuery &&
isSkillsAgentScopeCurrent(state, agentScope),
() =>
client.request<{ results: ClawHubSearchResult[] }>("skills.search", {
query,
limit: 20,
}),
(res) => {
state.clawhubSearchResults = res?.results ?? [];
},
(err) => {
state.clawhubSearchError = getErrorMessage(err);
},
() => {
state.clawhubSearchLoading = false;
},
const response = await client.request<{ results: ClawHubSearchResult[] }>(
"skills.search",
{ query, limit: 20 },
{ signal },
);
return response?.results ?? [];
}
export async function loadClawHubDetail(state: SkillsState, slug: string) {
+44 -64
View File
@@ -1,6 +1,7 @@
import "../../styles/config.css";
import "../../styles/config-quick.css";
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
@@ -283,8 +284,6 @@ export class ConfigPage extends OpenClawLightDomElement {
private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null;
private systemInfoGatewaySource: ApplicationContext["gateway"] | null = null;
private systemInfoClient: GatewayBrowserClient | null = null;
private systemInfoLoading = false;
private systemInfoRequestId = 0;
private sessionObserverModelsClient: GatewayBrowserClient | null = null;
private readonly sessionObserverModelLoads = new WeakMap<GatewayBrowserClient, Promise<void>>();
private readonly sessionObserverModelFailures = new WeakSet<GatewayBrowserClient>();
@@ -292,10 +291,35 @@ export class ConfigPage extends OpenClawLightDomElement {
this,
SESSION_OBSERVER_STATUS_POLL_INTERVAL_MS,
() => {
void this.loadSystemInfo();
if (this.systemInfoTask.status !== TaskStatus.PENDING) {
void this.systemInfoTask.run();
}
},
false,
);
private readonly systemInfoTask = new Task(this, {
autoRun: false,
// Null is an explicit visibility/capability invalidation for the current source.
args: () => [this.systemInfoGatewaySource, this.systemInfoRequestClient()] as const,
task: ([gateway, client], { signal }) =>
gateway && client
? client.request<SystemInfoResult>("system.info", {}, { signal })
: initialState,
onComplete: (systemInfo) => {
this.systemInfo = systemInfo;
const client = this.systemInfoRequestClient();
if (client) {
void this.ensureSessionObserverModels(client);
}
},
onError: (error) => {
if (isMissingOperatorReadScopeError(error) || isUnknownSystemInfoMethodError(error)) {
this.systemInfo = null;
this.systemInfoUnavailable = true;
this.systemInfoPolling.stop();
}
},
});
private pendingRouteTargetId: string | null = null;
private readonly subscriptions = new SubscriptionsController(this)
.watch(
@@ -559,10 +583,10 @@ export class ConfigPage extends OpenClawLightDomElement {
this.systemInfo = null;
}
}
this.syncSystemInfoPolling();
this.syncSystemInfoPolling(clientChanged);
}
private syncSystemInfoPolling() {
private syncSystemInfoPolling(forceRefresh = false) {
const gateway = this.context.gateway.snapshot;
const shouldPoll =
this.isConnected &&
@@ -575,75 +599,31 @@ export class ConfigPage extends OpenClawLightDomElement {
this.systemInfoPolling.stop();
return;
}
if (this.systemInfoPolling.start()) {
void this.loadSystemInfo();
if (this.systemInfoPolling.start() || forceRefresh) {
void this.systemInfoTask.run();
}
}
private invalidateSystemInfoRequest() {
this.systemInfoRequestId += 1;
this.systemInfoLoading = false;
void this.systemInfoTask.run([null, null]);
}
private isCurrentSystemInfoRequest(
requestId: number,
client: GatewayBrowserClient,
gatewaySource: ApplicationContext["gateway"],
): boolean {
const gateway = gatewaySource.snapshot;
return (
this.isConnected &&
this.isSystemInfoVisible() &&
requestId === this.systemInfoRequestId &&
this.systemInfoGatewaySource === gatewaySource &&
this.context.gateway === gatewaySource &&
gateway.phase === "connected" &&
gateway.client === client
);
}
private async loadSystemInfo() {
private systemInfoRequestClient(): GatewayBrowserClient | null {
const gatewaySource = this.systemInfoGatewaySource;
if (!gatewaySource || gatewaySource !== this.context.gateway) {
return;
}
const gateway = gatewaySource.snapshot;
const client = gateway.client;
const gateway = gatewaySource?.snapshot;
if (
gateway.phase !== "connected" ||
!client ||
!gatewaySource ||
!gateway ||
!this.isConnected ||
!this.isSystemInfoVisible() ||
this.systemInfoUnavailable ||
this.systemInfoLoading
this.context.gateway !== gatewaySource ||
gateway.phase !== "connected" ||
!supportsSystemInfo(gateway.hello) ||
this.systemInfoUnavailable
) {
return;
}
const requestId = ++this.systemInfoRequestId;
this.systemInfoLoading = true;
try {
const response = await client.request("system.info", {});
if (!this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
return;
}
this.systemInfo = response as SystemInfoResult;
if (this.pageId === "appearance") {
void this.ensureSessionObserverModels(client);
}
} catch (error) {
if (!this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
return;
}
if (isMissingOperatorReadScopeError(error) || isUnknownSystemInfoMethodError(error)) {
this.systemInfo = null;
this.systemInfoUnavailable = true;
this.systemInfoPolling.stop();
}
} finally {
if (this.isCurrentSystemInfoRequest(requestId, client, gatewaySource)) {
this.systemInfoLoading = false;
}
return null;
}
return gateway.client;
}
private ensureSessionObserverModels(client: GatewayBrowserClient): Promise<void> {
@@ -975,7 +955,7 @@ export class ConfigPage extends OpenClawLightDomElement {
})
.then((saved) => {
if (saved) {
void this.loadSystemInfo();
void this.systemInfoTask.run();
}
});
},
+47 -71
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { EventLogEntry } from "../../api/event-log.ts";
@@ -19,19 +20,12 @@ import { renderDebug } from "./view.ts";
const DEBUG_POLL_INTERVAL_MS = 3000;
type DebugRequestScope = {
gateway: ApplicationContext["gateway"];
client: GatewayBrowserClient;
generation: number;
};
class DebugPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@state() private connected = false;
@state() private debugLoading = false;
@state() private debugStatus: StatusSummary | null = null;
@state() private debugHealth: HealthSnapshot | null = null;
@state() private debugModels: unknown[] = [];
@@ -52,7 +46,25 @@ class DebugPage extends OpenClawLightDomElement {
);
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private requestGeneration = 0;
private callEpoch = 0;
private diagnosticsTaskActiveClient: GatewayBrowserClient | null = null;
private readonly diagnosticsTask = new Task(this, {
autoRun: false,
args: () => [this.connected ? this.client : null] as const,
task: ([client], { signal }) =>
client ? loadGatewayDiagnostics(client, signal) : initialState,
onComplete: (result) => {
this.diagnosticsTaskActiveClient = null;
this.debugStatus = result.status;
this.debugHealth = result.health;
this.debugModels = result.models;
this.debugHeartbeat = result.heartbeat;
},
onError: (error) => {
this.diagnosticsTaskActiveClient = null;
this.debugCallError = String(error);
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
@@ -60,7 +72,6 @@ class DebugPage extends OpenClawLightDomElement {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
this.requestGeneration += 1;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
@@ -80,9 +91,10 @@ class DebugPage extends OpenClawLightDomElement {
override disconnectedCallback() {
this.subscriptions.clear();
this.requestGeneration += 1;
void this.diagnosticsTask.run([null]);
this.diagnosticsTaskActiveClient = null;
this.callEpoch += 1;
this.gatewaySource = null;
this.debugLoading = false;
super.disconnectedCallback();
}
@@ -90,21 +102,20 @@ class DebugPage extends OpenClawLightDomElement {
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
this.requestGeneration += 1;
void this.diagnosticsTask.run([null]);
this.diagnosticsTaskActiveClient = null;
this.callEpoch += 1;
}
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
if (clientChanged) {
this.resetServerState();
} else if (connectionChanged) {
this.debugLoading = false;
}
this.syncPolling();
this.ensureInitialDebug();
}
private resetServerState() {
this.debugLoading = false;
this.debugStatus = null;
this.debugHealth = null;
this.debugModels = [];
@@ -122,81 +133,46 @@ class DebugPage extends OpenClawLightDomElement {
}
private ensureInitialDebug() {
if (!this.connected || !this.client || this.debugStatus || this.debugLoading) {
if (!this.connected || !this.client || this.debugStatus || this.diagnosticsTaskActiveClient) {
return;
}
void this.loadDiagnostics();
}
private captureRequestScope(): DebugRequestScope | null {
const gateway = this.gatewaySource;
const client = this.client;
if (
!gateway ||
!client ||
!this.connected ||
!this.isConnected ||
this.context.gateway !== gateway
) {
return null;
}
return { gateway, client, generation: this.requestGeneration };
}
private isRequestScopeCurrent(scope: DebugRequestScope): boolean {
return (
this.isConnected &&
this.gatewaySource === scope.gateway &&
this.context.gateway === scope.gateway &&
this.requestGeneration === scope.generation &&
this.client === scope.client &&
this.connected
);
}
private async loadDiagnostics() {
const scope = this.captureRequestScope();
if (!scope || this.debugLoading) {
return;
}
this.debugLoading = true;
try {
const result = await loadGatewayDiagnostics(scope.client);
if (!this.isRequestScopeCurrent(scope)) {
return;
}
this.debugStatus = result.status;
this.debugHealth = result.health;
this.debugModels = result.models;
this.debugHeartbeat = result.heartbeat;
} catch (err) {
if (this.isRequestScopeCurrent(scope)) {
this.debugCallError = String(err);
}
} finally {
if (this.isRequestScopeCurrent(scope)) {
this.debugLoading = false;
}
private loadDiagnostics(): Promise<void> {
const client = this.connected ? this.client : null;
if (!client || this.diagnosticsTaskActiveClient) {
return Promise.resolve();
}
this.diagnosticsTaskActiveClient = client;
return this.diagnosticsTask.run([client]);
}
private async callDebugMethod() {
const scope = this.captureRequestScope();
if (!scope) {
const client = this.connected ? this.client : null;
if (!client) {
return;
}
this.debugCallError = null;
this.debugCallResult = null;
const gateway = this.gatewaySource;
const epoch = this.callEpoch;
const isCurrent = () =>
this.connected &&
this.client === client &&
this.gatewaySource === gateway &&
this.context.gateway === gateway &&
this.callEpoch === epoch;
try {
const params = this.debugCallParams.trim()
? (JSON.parse(this.debugCallParams) as unknown)
: {};
const res = await scope.client.request(this.debugCallMethod.trim(), params);
if (this.isRequestScopeCurrent(scope)) {
const res = await client.request(this.debugCallMethod.trim(), params);
if (isCurrent()) {
this.debugCallResult = JSON.stringify(res, null, 2);
}
} catch (err) {
if (this.isRequestScopeCurrent(scope)) {
if (isCurrent()) {
this.debugCallError = String(err);
}
}
@@ -204,7 +180,7 @@ class DebugPage extends OpenClawLightDomElement {
override render() {
const body = renderDebug({
loading: this.debugLoading,
loading: this.diagnosticsTask.status === TaskStatus.PENDING,
status: this.debugStatus,
health: this.debugHealth,
models: this.debugModels,
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { TaskStatus } from "@lit/task";
import { nothing } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
@@ -633,22 +634,21 @@ describe("gateway source replacement across reconnect with a reused client", ()
const context = contextWithClient(client);
const page = createPage("openclaw-debug-page", context) as TestPage & {
connected: boolean;
debugLoading: boolean;
debugStatus: unknown;
loadDiagnostics: () => Promise<void>;
diagnosticsTask: { run: () => Promise<void>; status: TaskStatus };
};
document.body.append(page);
await page.updateComplete;
(context.gateway.snapshot as ApplicationGatewaySnapshot).phase = "connected";
page.connected = true;
const load = page.loadDiagnostics();
const load = page.diagnosticsTask.run();
await waitForFast(() => expect(request).toHaveBeenCalledTimes(4));
await replaceContext(page, client);
pending.resolve({ models: [{ id: "stale" }], stale: true });
await load;
expect(page.debugLoading).toBe(false);
expect(page.diagnosticsTask.status).not.toBe(TaskStatus.PENDING);
expect(page.debugStatus).toBeNull();
});
+86 -107
View File
@@ -1,5 +1,6 @@
import "../../styles/logs.css";
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -35,19 +36,12 @@ import { renderLogs } from "./view.ts";
const LOG_BUFFER_LIMIT = 2000;
const LOGS_POLL_INTERVAL_MS = 2000;
type LogsRequestScope = {
gateway: ApplicationContext["gateway"];
client: GatewayBrowserClient;
generation: number;
};
class LogsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
@state() private client: GatewayBrowserClient | null = null;
@state() private connected = false;
@state() private logsLoading = false;
@state() private logsStatus = createPanelRefreshStatus();
@state() private logsFile: string | null = null;
@state() private logsEntries: LogEntry[] = [];
@@ -70,15 +64,79 @@ class LogsPage extends OpenClawLightDomElement {
private contentScrollFrame: number | null = null;
private hasBoundGatewaySource = false;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private requestGeneration = 0;
private activeRequest: LogsRequestScope | null = null;
private logsTaskQuiet = false;
private logsTaskArgs(opts?: { reset?: boolean; quiet?: boolean }) {
return [
this.connected ? this.gatewaySource : null,
this.connected ? this.client : null,
opts?.reset ? null : this.logsCursor,
opts?.reset === true,
opts?.quiet === true,
] as const;
}
private readonly logsTask = new Task(this, {
autoRun: false,
// The cursor and reset flag make each tail page an explicit immutable read.
args: () => this.logsTaskArgs(),
task: async ([gateway, client, cursor, reset, quiet], { signal }) => {
if (!gateway || !client) {
return initialState;
}
try {
const payload = await client.request<{
file?: string;
cursor?: number;
lines?: unknown;
truncated?: boolean;
reset?: boolean;
}>(
"logs.tail",
{
cursor: reset ? undefined : (cursor ?? undefined),
limit: this.logsLimit,
maxBytes: this.logsMaxBytes,
},
{ signal },
);
return { ok: true as const, payload, cursor, reset, quiet };
} catch (error) {
return { ok: false as const, error, quiet };
}
},
onComplete: (result) => {
if (!result.ok) {
if (isMissingOperatorReadScopeError(result.error)) {
this.logsEntries = [];
this.logsStatus = failPanelRefresh(
createPanelRefreshStatus(),
formatMissingOperatorReadScopeMessage("logs"),
);
} else {
this.logsStatus = failPanelRefresh(this.logsStatus, String(result.error));
}
return;
}
const lines = Array.isArray(result.payload.lines)
? result.payload.lines.filter((line): line is string => typeof line === "string")
: [];
const entries = lines.map(parseLogLine);
const shouldReset = result.reset || result.payload.reset || result.cursor == null;
this.logsEntries = shouldReset
? entries
: [...this.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
this.logsCursor =
typeof result.payload.cursor === "number" ? result.payload.cursor : this.logsCursor;
this.logsFile = typeof result.payload.file === "string" ? result.payload.file : this.logsFile;
this.logsTruncated = Boolean(result.payload.truncated);
this.logsStatus = completePanelRefresh();
},
});
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
const resetForSourceBind = this.hasBoundGatewaySource;
this.hasBoundGatewaySource = true;
this.gatewaySource = gateway;
this.requestGeneration += 1;
const cleanup = gateway.subscribe((snapshot) => {
if (this.gatewaySource === gateway && this.context.gateway === gateway) {
this.applyGatewaySnapshot(snapshot);
@@ -94,14 +152,14 @@ class LogsPage extends OpenClawLightDomElement {
isEnabled: () => this.logsAutoFollow,
captureCurrent: () => {
const gateway = this.gatewaySource;
const generation = this.requestGeneration;
const client = this.client;
return () =>
this.isConnected &&
this.connected &&
gateway !== null &&
this.gatewaySource === gateway &&
this.context.gateway === gateway &&
this.requestGeneration === generation;
this.client === client;
},
});
@@ -125,10 +183,9 @@ class LogsPage extends OpenClawLightDomElement {
override disconnectedCallback() {
this.subscriptions.clear();
this.requestGeneration += 1;
this.activeRequest = null;
this.logsTaskQuiet = false;
void this.logsTask.run([null, null, null, false, false]);
this.gatewaySource = null;
this.logsLoading = false;
if (this.contentScrollFrame !== null) {
cancelAnimationFrame(this.contentScrollFrame);
this.contentScrollFrame = null;
@@ -148,22 +205,19 @@ class LogsPage extends OpenClawLightDomElement {
const connectionChanged = (snapshot.phase === "connected") !== this.connected;
const clientChanged = resetForSourceBind || snapshot.client !== this.client;
if (clientChanged || connectionChanged) {
this.requestGeneration += 1;
this.activeRequest = null;
this.logsTaskQuiet = false;
void this.logsTask.run([null, null, null, false, false]);
}
this.client = snapshot.client;
this.connected = snapshot.phase === "connected";
if (clientChanged) {
this.resetServerState();
} else if (connectionChanged) {
this.logsLoading = false;
}
this.syncPolling();
this.ensureInitialLogs();
}
private resetServerState() {
this.logsLoading = false;
this.logsStatus = createPanelRefreshStatus();
this.logsFile = null;
this.logsEntries = [];
@@ -181,7 +235,7 @@ class LogsPage extends OpenClawLightDomElement {
}
private ensureInitialLogs() {
if (!this.connected || !this.client || this.logsEntries.length > 0 || this.logsLoading) {
if (!this.connected || !this.client || this.logsEntries.length > 0) {
return;
}
void this.loadLogs({ reset: true }).then((current) => {
@@ -191,96 +245,21 @@ class LogsPage extends OpenClawLightDomElement {
});
}
private captureRequestScope(): LogsRequestScope | null {
const gateway = this.gatewaySource;
const client = this.client;
if (
!gateway ||
!client ||
!this.connected ||
!this.isConnected ||
this.context.gateway !== gateway
) {
return null;
}
return { gateway, client, generation: this.requestGeneration };
}
private isRequestScopeCurrent(scope: LogsRequestScope): boolean {
return (
this.isConnected &&
this.gatewaySource === scope.gateway &&
this.context.gateway === scope.gateway &&
this.requestGeneration === scope.generation &&
this.client === scope.client &&
this.connected
);
}
private async loadLogs(opts?: { reset?: boolean; quiet?: boolean }): Promise<boolean> {
const scope = this.captureRequestScope();
const quiet = opts?.quiet === true;
if (!scope || (this.activeRequest && this.isRequestScopeCurrent(this.activeRequest))) {
if (
!this.gatewaySource ||
!this.client ||
!this.connected ||
this.context.gateway !== this.gatewaySource ||
(this.logsTask.status === TaskStatus.PENDING && opts?.reset !== true)
) {
return false;
}
this.activeRequest = scope;
const isCurrentOperation = () =>
this.activeRequest === scope && this.isRequestScopeCurrent(scope);
if (!quiet) {
this.logsLoading = true;
}
this.logsTaskQuiet = quiet;
this.logsStatus = beginPanelRefresh(this.logsStatus, { clearError: !quiet });
try {
const res = await scope.client.request("logs.tail", {
cursor: opts?.reset ? undefined : (this.logsCursor ?? undefined),
limit: this.logsLimit,
maxBytes: this.logsMaxBytes,
});
if (!isCurrentOperation()) {
return false;
}
const payload = res as {
file?: string;
cursor?: number;
lines?: unknown;
truncated?: boolean;
reset?: boolean;
};
const lines = Array.isArray(payload.lines)
? payload.lines.filter((line): line is string => typeof line === "string")
: [];
const entries = lines.map(parseLogLine);
const shouldReset = opts?.reset || payload.reset || this.logsCursor == null;
this.logsEntries = shouldReset
? entries
: [...this.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
this.logsCursor = typeof payload.cursor === "number" ? payload.cursor : this.logsCursor;
this.logsFile = typeof payload.file === "string" ? payload.file : this.logsFile;
this.logsTruncated = Boolean(payload.truncated);
this.logsStatus = completePanelRefresh();
return true;
} catch (err) {
if (!isCurrentOperation()) {
return false;
}
if (isMissingOperatorReadScopeError(err)) {
this.logsEntries = [];
this.logsStatus = failPanelRefresh(
createPanelRefreshStatus(),
formatMissingOperatorReadScopeMessage("logs"),
);
} else {
this.logsStatus = failPanelRefresh(this.logsStatus, String(err));
}
return true;
} finally {
if (this.activeRequest === scope) {
this.activeRequest = null;
if (this.isRequestScopeCurrent(scope) && !quiet) {
this.logsLoading = false;
}
}
}
await this.logsTask.run(this.logsTaskArgs(opts));
return this.logsTask.status === TaskStatus.COMPLETE;
}
private exportLogs(lines: string[], label: string) {
@@ -299,7 +278,7 @@ class LogsPage extends OpenClawLightDomElement {
override render() {
const body = renderLogs({
loading: this.logsLoading,
loading: this.logsTask.status === TaskStatus.PENDING && !this.logsTaskQuiet,
status: this.logsStatus,
file: this.logsFile,
entries: this.logsEntries,
+13 -9
View File
@@ -57,8 +57,14 @@ function errorMessage(error: unknown): string {
export async function loadModelProvidersData(
client: GatewayBrowserClient,
opts?: { refresh?: boolean; agentId?: string },
opts?: { refresh?: boolean; agentId?: string; signal?: AbortSignal },
): Promise<ModelProvidersData> {
const request = <T>(method: string, params?: unknown): Promise<T> =>
opts?.signal
? client.request<T>(method, params, { signal: opts.signal })
: params === undefined
? client.request<T>(method)
: client.request<T>(method, params);
const [authStatus, models, catalogModels, config, providerUsage, costByProvider] =
await Promise.all([
loadModelAuthStatus(client, opts).then(
@@ -66,18 +72,16 @@ export async function loadModelProvidersData(
(error: unknown) => ({ ok: false as const, error }),
),
loadModels(client, opts).catch(() => null),
client
.request<{ models?: ModelCatalogEntry[] }>("models.list", {
view: "all",
includeProviderCapabilities: true,
})
request<{ models?: ModelCatalogEntry[] }>("models.list", {
view: "all",
includeProviderCapabilities: true,
})
.then((result) => result?.models ?? null)
.catch(() => null),
client
.request<ConfigSnapshot>("config.get", {})
request<ConfigSnapshot>("config.get", {})
.then((snapshot) => resolveEditableSnapshotConfig(snapshot))
.catch(() => null),
client.request<UsageSummary>("usage.status").catch(() => null),
request<UsageSummary>("usage.status").catch(() => null),
requestSessionUsage(client, {
startDate: localDate(MODEL_PROVIDERS_COST_DAYS - 1),
endDate: localDate(0),
@@ -15,8 +15,6 @@ type ModelProvidersPageTestElement = HTMLElement & {
data: ModelProvidersData | null;
probe: (cardId: string, providers: string[]) => Promise<void>;
probeResults: Record<string, ModelsProbeResult>;
refreshQueue: Promise<void>;
refreshing: boolean;
routeData: ModelProvidersRouteData | undefined;
selectedAgentId: string;
};
@@ -202,7 +200,11 @@ describe("ModelProvidersPage agent scope", () => {
const page = appendPage(context);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "main" }),
expect(request).toHaveBeenCalledWith(
"models.authStatus",
{ agentId: "main" },
{ signal: expect.any(AbortSignal) },
),
);
request.mockClear();
@@ -211,9 +213,12 @@ describe("ModelProvidersPage agent scope", () => {
notifySelection();
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }),
expect(request).toHaveBeenCalledWith(
"models.authStatus",
{ agentId: "writer" },
{ signal: expect.any(AbortSignal) },
),
);
await page.refreshQueue;
expect(request.mock.calls.filter(([method]) => method === "models.authStatus")).toHaveLength(1);
expect(page.busy).toEqual({});
});
@@ -225,7 +230,11 @@ describe("ModelProvidersPage agent scope", () => {
const page = appendPage(context);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "main" }),
expect(request).toHaveBeenCalledWith(
"models.authStatus",
{ agentId: "main" },
{ signal: expect.any(AbortSignal) },
),
);
// Invalidate the in-flight refresh mid-await; the stale completion must
// clear `refreshing` so the new agent's load can proceed.
@@ -234,10 +243,13 @@ describe("ModelProvidersPage agent scope", () => {
release();
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }),
expect(request).toHaveBeenCalledWith(
"models.authStatus",
{ agentId: "writer" },
{ signal: expect.any(AbortSignal) },
),
);
await page.refreshQueue;
expect(page.refreshing).toBe(false);
await vi.waitFor(() => expect(page.data?.updatedAt).toEqual(expect.any(Number)));
});
it("discards stale route data when selection changes during preload", async () => {
@@ -251,7 +263,11 @@ describe("ModelProvidersPage agent scope", () => {
document.body.append(page);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "writer" }),
expect(request).toHaveBeenCalledWith(
"models.authStatus",
{ agentId: "writer" },
{ signal: expect.any(AbortSignal) },
),
);
expect(page.selectedAgentId).toBe("writer");
expect(page.data).not.toBe(staleData);
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
@@ -95,7 +96,6 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
@property({ attribute: false }) routeData: ModelProvidersRouteData | undefined;
@state() private data: ModelProvidersData | null = null;
@state() private refreshing = false;
@state() private busy: Record<string, boolean> = {};
@state() private messages: Record<string, ModelProviderRowMessage> = {};
@state() private probeResults: Record<string, ModelsProbeResult> = {};
@@ -113,9 +113,30 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
private dataClient: GatewayBrowserClient | null = null;
private observedClient: GatewayBrowserClient | null = null;
private clientEpoch = 0;
private refreshEpoch = 0;
private refreshQueue: Promise<void> = Promise.resolve();
private probeEpochs = new Map<string, number>();
private readonly refreshTask = new Task(this, {
autoRun: false,
args: () =>
[
this.context?.gateway.snapshot.phase === "connected"
? (this.context.gateway.snapshot.client ?? null)
: null,
this.selectedAgentId,
false as boolean,
] as const,
task: ([client, agentId, force], { signal }) =>
client
? loadModelProvidersData(client, {
agentId,
...(force ? { refresh: true } : {}),
signal,
}).then((data) => ({ client, data }))
: initialState,
onComplete: ({ client, data }) => {
this.data = data;
this.dataClient = client;
},
});
private readonly subscriptions = new SubscriptionsController(this)
.watch(
() => this.context?.gateway,
@@ -145,7 +166,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
);
override disconnectedCallback() {
this.refreshEpoch += 1;
void this.refreshTask.run([null, this.selectedAgentId, false]);
this.subscriptions.clear();
super.disconnectedCallback();
}
@@ -172,7 +193,11 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
if (!this.context.agents.state.agentsList && !this.context.agents.state.agentsLoading) {
void this.context.agents.ensureList();
}
if (snapshot.phase !== "connected" || !snapshot.client || this.refreshing) {
if (
snapshot.phase !== "connected" ||
!snapshot.client ||
this.refreshTask.status === TaskStatus.PENDING
) {
return;
}
const stale = this.data === null || this.data.updatedAt === null;
@@ -184,8 +209,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
private resetClientState(client: GatewayBrowserClient | null) {
this.observedClient = client;
this.clientEpoch += 1;
this.refreshEpoch += 1;
this.refreshing = false;
void this.refreshTask.run([null, this.selectedAgentId, false]);
this.busy = {};
this.messages = {};
this.probeResults = {};
@@ -228,7 +252,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
return;
}
this.selectedAgentId = agentId;
this.refreshEpoch += 1;
void this.refreshTask.run([null, agentId, false]);
this.data = null;
this.busy = {};
this.pendingLogoutProvider = null;
@@ -241,40 +265,11 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
}
private refresh(opts: { force: boolean }): Promise<void> {
const task = this.refreshQueue.then(() => this.performRefresh(opts));
this.refreshQueue = task.catch(() => undefined);
return task;
}
private async performRefresh(opts: { force: boolean }) {
const client = this.context.gateway.snapshot.client;
if (!client) {
return;
}
const agentId = this.selectedAgentId;
const epoch = ++this.refreshEpoch;
this.refreshing = true;
try {
const data = await loadModelProvidersData(client, {
agentId,
...(opts.force ? { refresh: true } : {}),
});
if (
epoch === this.refreshEpoch &&
this.selectedAgentId === agentId &&
this.context.gateway.snapshot.client === client
) {
this.data = data;
this.dataClient = client;
}
} finally {
// refreshQueue serializes performRefresh calls, so this is always the
// only in-flight refresh: clear unconditionally. An epoch-guarded clear
// orphans `refreshing` when a selection change invalidates us mid-await,
// permanently blocking maybeRefresh for the new agent.
this.refreshing = false;
this.requestUpdate();
return Promise.resolve();
}
return this.refreshTask.run([client, this.selectedAgentId, opts.force]);
}
private mutationBlockedReason(): string | null {
@@ -625,7 +620,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
const body = renderModelProviders({
connected: gatewaySnapshot.phase === "connected",
loading: gatewaySnapshot.phase === "connected" && this.data === null,
refreshing: this.refreshing,
refreshing: this.refreshTask.status === TaskStatus.PENDING,
error: data.error,
updatedAt: data.updatedAt,
costDays: MODEL_PROVIDERS_COST_DAYS,
@@ -160,9 +160,11 @@ describe("new-session model runtime", () => {
const control = new NewSessionModelControl(() => undefined);
control.load(context, "main", true);
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("chat.metadata", {
agentId: "main",
}),
expect(request).toHaveBeenCalledWith(
"chat.metadata",
{ agentId: "main" },
{ signal: expect.any(AbortSignal) },
),
);
await vi.waitFor(() => {
control.selected = "openai/gpt-5.6-luna";
+55 -44
View File
@@ -1,3 +1,5 @@
import { initialState, Task, TaskStatus } from "@lit/task";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { GatewayAgentRow, GatewaySessionRow, ModelCatalogEntry } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
import {
@@ -50,13 +52,45 @@ function resolveDraftModelTarget(
};
}
export class NewSessionModelControl {
private requestToken = 0;
export class NewSessionModelControl implements ReactiveControllerHost {
private selectionGeneration = 0;
private agentId = "";
private catalog: ModelCatalogEntry[] = [];
private loading = false;
private restoringPreference = false;
private pendingPreference: NewSessionPreference | null | undefined;
private pendingAgent: GatewayAgentRow | undefined;
private pendingContext: ApplicationContext | undefined;
private pendingSelectionGeneration = 0;
private readonly catalogTask = new Task(this, {
autoRun: false,
args: () =>
[null as ApplicationContext["gateway"]["snapshot"]["client"], "" as string] as const,
task: ([client, agentId], { signal }) =>
client
? client.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", { agentId }, { signal })
: initialState,
onComplete: (result) => {
this.catalog = Array.isArray(result.models) ? result.models : [];
if (this.pendingSelectionGeneration === this.selectionGeneration) {
this.restorePreference(this.pendingPreference, this.pendingAgent, this.pendingContext);
}
this.restoringPreference = false;
},
onError: () => {
this.catalog = [];
if (
this.pendingSelectionGeneration === this.selectionGeneration &&
(this.pendingPreference?.model || this.pendingPreference?.thinkingLevel)
) {
// A transport failure says nothing about current availability.
// Preserve the requested pair so sessions.create remains the
// authoritative validator instead of silently using defaults.
this.selected = this.pendingPreference.model ?? "";
this.thinkingLevel = this.pendingPreference.thinkingLevel ?? "";
}
this.restoringPreference = false;
},
});
selected = "";
thinkingLevel = "";
@@ -68,9 +102,18 @@ export class NewSessionModelControl {
}) => void = () => undefined,
) {}
readonly updateComplete = Promise.resolve(true);
addController(_controller: ReactiveController): void {}
removeController(_controller: ReactiveController): void {}
requestUpdate(): void {
this.notify();
}
invalidate(resetSelection = false) {
this.requestToken += 1;
this.loading = false;
void this.catalogTask.run([null, ""]);
this.restoringPreference = false;
this.catalog = [];
if (resetSelection) {
@@ -99,54 +142,22 @@ export class NewSessionModelControl {
this.selected = "";
this.thinkingLevel = "";
}
const requestId = ++this.requestToken;
const selectionGeneration = this.selectionGeneration;
this.catalog = [];
if (snapshot?.phase !== "connected" || !client || !normalizedAgentId || !enabled) {
this.loading = false;
void this.catalogTask.run([null, ""]);
this.restoringPreference = false;
this.notify();
return;
}
this.loading = true;
this.pendingPreference = options.preference;
this.pendingAgent = options.agent;
this.pendingContext = context;
this.pendingSelectionGeneration = selectionGeneration;
this.restoringPreference = Boolean(
options.preference?.model || options.preference?.thinkingLevel,
);
this.notify();
void client
.request<{ models?: ModelCatalogEntry[] }>("chat.metadata", {
agentId: normalizedAgentId,
})
.then((result) => {
if (requestId === this.requestToken) {
this.catalog = Array.isArray(result.models) ? result.models : [];
if (selectionGeneration === this.selectionGeneration) {
this.restorePreference(options.preference, options.agent, context);
}
}
})
.catch(() => {
if (requestId === this.requestToken) {
this.catalog = [];
if (
selectionGeneration === this.selectionGeneration &&
(options.preference?.model || options.preference?.thinkingLevel)
) {
// A transport failure says nothing about current availability.
// Preserve the requested pair so sessions.create remains the
// authoritative validator instead of silently using defaults.
this.selected = options.preference?.model ?? "";
this.thinkingLevel = options.preference?.thinkingLevel ?? "";
}
}
})
.finally(() => {
if (requestId === this.requestToken) {
this.loading = false;
this.restoringPreference = false;
this.notify();
}
});
void this.catalogTask.run([client, normalizedAgentId]);
}
isRestoringPreference(): boolean {
@@ -293,7 +304,7 @@ export class NewSessionModelControl {
modelCatalog: this.catalog,
modelOverrides: { [sessionKey]: this.selected },
modelSwitching: false,
modelsLoading: this.loading,
modelsLoading: this.catalogTask.status === TaskStatus.PENDING,
sending: options.sending,
sessionKey,
sessionsResult: sourceResult,
+28 -36
View File
@@ -1,7 +1,7 @@
import { consume } from "@lit/context";
import { initialState, Task } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { PresenceEntry } from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
import {
@@ -98,8 +98,28 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
private routeDataInitialized = false;
private hasBoundGateway = false;
private presenceRequestId = 0;
private gatewaySource: ApplicationContext["gateway"] | null = null;
private readonly presenceTask = new Task(this, {
autoRun: false,
// Gateway identity invalidates same-client reconnects and source replacements.
args: () =>
[
this.connected ? this.gatewaySource : null,
this.connected ? this.context?.gateway.snapshot.client : null,
] as const,
task: ([gateway, client], { signal }) =>
gateway && client ? client.request("system-presence", {}, { signal }) : initialState,
onComplete: (response) => {
if (Array.isArray(response)) {
this.presence = response as PresenceEntry[];
}
},
onError: (error) => {
if (isMissingOperatorReadScopeError(error)) {
this.presence = [];
}
},
});
private readonly polling = new PollController(
this,
NODES_ACTIVE_POLL_INTERVAL_MS,
@@ -146,7 +166,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
const connectivityChanged =
presenceConnectivitySignature(presence) !==
presenceConnectivitySignature(this.presence);
this.presenceRequestId += 1;
void this.presenceTask.run([null, null]);
this.presence = presence;
if (connectivityChanged) {
void loadDevices(this, { quiet: true });
@@ -177,7 +197,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
override disconnectedCallback() {
this.subscriptions.clear();
this.requestGeneration += 1;
this.presenceRequestId += 1;
void this.presenceTask.run([null, null]);
this.client = null;
this.connected = false;
this.presence = [];
@@ -269,7 +289,7 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
});
this.nodesLoading = next.nodesLoading;
this.nodes = next.nodes;
this.presenceRequestId += 1;
void this.presenceTask.run([null, null]);
this.presence = [];
this.lastError = next.lastError;
this.chatError = next.chatError ?? null;
@@ -311,41 +331,13 @@ class NodesPage extends OpenClawLightDomElement implements NodesPageDataState {
this.polling.stop();
}
private async loadPresence() {
private loadPresence(): Promise<void> {
const gateway = this.context.gateway.snapshot;
const client = gateway.client;
if (gateway.phase !== "connected" || !client) {
return;
return Promise.resolve();
}
const generation = this.requestGeneration;
const requestId = ++this.presenceRequestId;
try {
const response = await client.request("system-presence", {});
if (this.isCurrentPresenceRequest(client, generation, requestId) && Array.isArray(response)) {
this.presence = response as PresenceEntry[];
}
} catch (error) {
if (
this.isCurrentPresenceRequest(client, generation, requestId) &&
isMissingOperatorReadScopeError(error)
) {
this.presence = [];
}
}
}
private isCurrentPresenceRequest(
client: GatewayBrowserClient,
generation: number,
requestId: number,
): boolean {
const snapshot = this.context.gateway.snapshot;
return (
snapshot.phase === "connected" &&
snapshot.client === client &&
this.requestGeneration === generation &&
this.presenceRequestId === requestId
);
return this.presenceTask.run([this.context.gateway, client]);
}
private confirmInventoryRemoval() {
@@ -17,4 +17,5 @@ export type SkillWorkshopRenderContext = {
selfLearning: SkillWorkshopSelfLearning | null;
onSelfLearningToggle: (enabled: boolean) => void;
onHistoryScan: () => void;
onRetry: () => void;
};
@@ -409,6 +409,12 @@ describe("SkillWorkshopPage lifecycle", () => {
await waitForSkillWorkshop(() =>
expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true),
);
await page.updateComplete;
await waitForSkillWorkshop(() =>
expect(page.querySelector<HTMLButtonElement>(".sw-history__action button")?.disabled).toBe(
false,
),
);
page.querySelector<HTMLButtonElement>(".sw-history__action button")?.click();
await waitForSkillWorkshop(() =>
@@ -473,6 +479,12 @@ describe("SkillWorkshopPage lifecycle", () => {
await waitForSkillWorkshop(() =>
expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true),
);
await page.updateComplete;
await waitForSkillWorkshop(() =>
expect(page.querySelector<HTMLButtonElement>(".sw-history__action button")?.disabled).toBe(
false,
),
);
page.querySelector<HTMLButtonElement>(".sw-history__action button")?.click();
await waitForSkillWorkshop(() =>
@@ -557,6 +569,12 @@ describe("SkillWorkshopPage lifecycle", () => {
await waitForSkillWorkshop(() =>
expect(page.state?.skillWorkshopHistoryScan.loaded).toBe(true),
);
await page.updateComplete;
await waitForSkillWorkshop(() =>
expect(page.querySelector<HTMLButtonElement>(".sw-history__action button")?.disabled).toBe(
false,
),
);
page.querySelector<HTMLButtonElement>(".sw-history__action button")?.click();
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task } from "@lit/task";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
@@ -28,7 +29,6 @@ import { selectPluginsHubTab } from "./plugins-hub-navigation.ts";
import {
countSkillWorkshopProposals,
createSkillWorkshopState,
loadSkillWorkshopProposals,
requestSkillWorkshopRevision,
runSkillWorkshopLifecycleAction,
selectSkillWorkshopProposal,
@@ -109,6 +109,7 @@ function renderSkillWorkshopPage(
selfLearning,
onSelfLearningToggle,
onHistoryScan,
onRetry,
} = renderContext;
const pageClass =
state.skillWorkshopMode === "today"
@@ -205,12 +206,7 @@ function renderSkillWorkshopPage(
historyScan: state.skillWorkshopHistoryScan,
counts: countSkillWorkshopProposals(state.skillWorkshopProposals),
onRetry: () => {
// Force past the loaded/error latch; the loading guard still
// prevents duplicate in-flight requests.
void loadSkillWorkshopProposals(state, context, { force: true }).finally(
requestUpdate,
);
requestUpdate();
onRetry();
},
onStatusFilterChange: (status) => {
state.skillWorkshopStatusFilter = status;
@@ -308,7 +304,7 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
@property({ attribute: false }) onRevisionRequest?: SkillWorkshopRevisionRequest;
private state?: SkillWorkshopState;
private sourceEpoch = 0;
private operationEpoch = 0;
private hasBoundContext = false;
private contextSource?: SkillWorkshopPageContext;
private gatewaySource?: SkillWorkshopPageContext["gateway"];
@@ -321,6 +317,25 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
private sessionsSource?: SkillWorkshopPageContext["sessions"];
private selfLearningBusy = false;
private selfLearningError: string | null = null;
private readonly proposalsTask = new Task(this, {
autoRun: false,
// State and context identities isolate helper mutations after any source reset.
args: () =>
[
this.gatewayConnected ? (this.context ?? null) : null,
this.gatewayConnected ? (this.state ?? null) : null,
this.selectedAgentId ?? null,
false as boolean,
] as const,
task: ([context, state, _agentId, force]) =>
context && state ? loadSkillWorkshopPageData({ state, context, force }) : initialState,
onComplete: () => {
this.requestPageUpdate();
},
onError: () => {
this.requestPageUpdate();
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context,
@@ -516,7 +531,8 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
};
private resetSourceState() {
this.sourceEpoch += 1;
this.operationEpoch += 1;
void this.proposalsTask.run([null, null, null, false]);
const previous = this.state;
if (!previous) {
return;
@@ -557,7 +573,7 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
return captureSkillWorkshopSourceScope({
state: this.state,
context: this.context,
epoch: this.sourceEpoch,
epoch: this.operationEpoch,
});
}
@@ -565,7 +581,7 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
return isCurrentSkillWorkshopSourceScope(scope, {
state: this.state,
context: this.context,
epoch: this.sourceEpoch,
epoch: this.operationEpoch,
});
}
@@ -575,7 +591,7 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
if (!state || !context || context.gateway.snapshot.phase !== "connected") {
return;
}
void loadSkillWorkshopPageData({ state, context, force }).finally(this.requestPageUpdate);
void this.proposalsTask.run([context, state, context.agentSelection.state.selectedId, force]);
}
private readonly handleHistoryScan = () => {
@@ -646,6 +662,7 @@ class SkillWorkshopPage extends OpenClawLightDomElement {
),
onSelfLearningToggle: this.handleSelfLearningToggle,
onHistoryScan: this.handleHistoryScan,
onRetry: () => this.loadProposals(true),
},
this.requestPageUpdate,
)
+84 -47
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -22,7 +23,6 @@ import {
reconcileSkillsAgentId,
saveSkillApiKey,
searchClawHub,
setClawHubSearchQuery,
setSkillsAgentId,
updateSkillEdit,
updateSkillEnabled,
@@ -70,9 +70,6 @@ class SkillsPage extends OpenClawLightDomElement {
@state() skillsDetailKey: string | null = null;
@state() skillsDetailTab: SkillDetailTab = "overview";
@state() clawhubSearchQuery = "";
@state() clawhubSearchResults: ClawHubSearchResult[] | null = null;
@state() clawhubSearchLoading = false;
@state() clawhubSearchError: string | null = null;
@state() clawhubDetail: ClawHubSkillDetail | null = null;
@state() clawhubDetailSlug: string | null = null;
@state() clawhubDetailLoading = false;
@@ -96,7 +93,37 @@ class SkillsPage extends OpenClawLightDomElement {
private routeDataInitialized = false;
private routeDataEnabled = true;
private hasBoundGatewaySource = false;
private sourceGeneration = 0;
private debouncedClawHubSearchQuery = "";
private readonly agentsTask = new Task(this, {
autoRun: false,
args: () =>
[
this.connected ? this.client : null,
this.connected ? (this.context?.agents ?? null) : null,
] as const,
task: ([client, agents]) => (client && agents ? agents.ensureList() : initialState),
onComplete: (agents) => {
if (!agents) {
return;
}
this.agentsList = agents;
const previousAgentId = this.skillsAgentId;
reconcileSkillsAgentId(this, agents);
if (previousAgentId !== this.skillsAgentId) {
this.skillsDetailKey = null;
this.skillsDetailTab = "overview";
}
},
onError: (error) => {
this.agentsError = String(error);
},
});
private readonly clawhubSearchTask = new Task(this, {
autoRun: false,
args: () => [this.connected ? this.client : null, this.debouncedClawHubSearchQuery] as const,
task: ([client, query], { signal }) =>
client && query ? searchClawHub(client, query, signal) : initialState,
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
@@ -165,7 +192,8 @@ class SkillsPage extends OpenClawLightDomElement {
}
private resetLoadedSkillState() {
this.sourceGeneration++;
void this.agentsTask.run([null, null]);
void this.clawhubSearchTask.run([null, ""]);
if (this.clawhubSearchTimer) {
clearTimeout(this.clawhubSearchTimer);
this.clawhubSearchTimer = null;
@@ -186,9 +214,7 @@ class SkillsPage extends OpenClawLightDomElement {
this.skillMessages = {};
this.skillsDetailKey = null;
this.skillsDetailTab = "overview";
this.clawhubSearchResults = null;
this.clawhubSearchLoading = false;
this.clawhubSearchError = null;
this.debouncedClawHubSearchQuery = "";
this.clawhubDetail = null;
this.clawhubDetailSlug = null;
this.clawhubDetailLoading = false;
@@ -252,11 +278,11 @@ class SkillsPage extends OpenClawLightDomElement {
}
if (
this.clawhubSearchQuery.trim() &&
!this.clawhubSearchLoading &&
!this.clawhubSearchResults &&
!this.clawhubSearchError
this.clawhubSearchTask.status !== TaskStatus.PENDING &&
this.clawhubSearchResults === null &&
this.clawhubSearchError === null
) {
void searchClawHub(this, this.clawhubSearchQuery);
this.runClawHubSearch(this.clawhubSearchQuery);
}
}
@@ -265,43 +291,13 @@ class SkillsPage extends OpenClawLightDomElement {
if (!client || !this.connected || this.agentsLoading) {
return;
}
const gatewaySource = this.context.gateway;
const agentsSource = this.context.agents;
const sourceGeneration = this.sourceGeneration;
const isCurrent = () =>
this.isConnected &&
this.connected &&
this.client === client &&
this.context.gateway === gatewaySource &&
this.context.agents === agentsSource &&
this.sourceGeneration === sourceGeneration;
if (agentsSource.state.agentsList) {
this.syncAgentState();
return;
}
this.agentsLoading = true;
this.agentsError = null;
try {
const agents = await agentsSource.ensureList();
if (!isCurrent()) {
return;
}
this.agentsList = agents;
const previousAgentId = this.skillsAgentId;
reconcileSkillsAgentId(this, agents);
if (previousAgentId !== this.skillsAgentId) {
this.skillsDetailKey = null;
this.skillsDetailTab = "overview";
}
} catch (err) {
if (isCurrent()) {
this.agentsError = String(err);
}
} finally {
if (isCurrent()) {
this.agentsLoading = false;
}
}
await this.agentsTask.run([client, agentsSource]);
}
private async refreshPage() {
@@ -322,11 +318,49 @@ class SkillsPage extends OpenClawLightDomElement {
}
private changeClawHubQuery(query: string) {
setClawHubSearchQuery(this, query);
this.clawhubSearchQuery = query;
this.clawhubInstallMessage = null;
this.debouncedClawHubSearchQuery = "";
void this.clawhubSearchTask.run([null, ""]);
if (this.clawhubSearchTimer) {
clearTimeout(this.clawhubSearchTimer);
}
this.clawhubSearchTimer = setTimeout(() => void searchClawHub(this, query), 300);
this.clawhubSearchTimer = setTimeout(() => this.runClawHubSearch(query), 300);
}
private runClawHubSearch(query: string) {
const normalizedQuery = query.trim();
this.debouncedClawHubSearchQuery = normalizedQuery;
if (!normalizedQuery || !this.connected || !this.client) {
void this.clawhubSearchTask.run([null, ""]);
return;
}
void this.clawhubSearchTask.run([this.client, normalizedQuery]);
}
get clawhubSearchResults(): ClawHubSearchResult[] | null {
return this.clawhubSearchTask.status === TaskStatus.COMPLETE &&
this.debouncedClawHubSearchQuery === this.clawhubSearchQuery.trim()
? (this.clawhubSearchTask.value ?? null)
: null;
}
get clawhubSearchLoading(): boolean {
return (
this.debouncedClawHubSearchQuery.length > 0 &&
this.clawhubSearchTask.status === TaskStatus.PENDING
);
}
get clawhubSearchError(): string | null {
if (
this.clawhubSearchTask.status !== TaskStatus.ERROR ||
this.debouncedClawHubSearchQuery !== this.clawhubSearchQuery.trim()
) {
return null;
}
const error = this.clawhubSearchTask.error;
return error instanceof Error ? error.message : String(error);
}
private changeDetailTab(tab: SkillDetailTab) {
@@ -367,7 +401,10 @@ class SkillsPage extends OpenClawLightDomElement {
>
${renderSkills({
connected: this.connected,
loading: this.skillsLoading || this.agentsLoading,
loading:
this.skillsLoading ||
this.agentsLoading ||
this.agentsTask.status === TaskStatus.PENDING,
report: this.skillsReport,
agentsList: this.agentsList,
selectedAgentId: this.skillsAgentId ?? this.agentsList?.defaultId ?? null,
+7 -1
View File
@@ -339,10 +339,12 @@ describe("TasksPage cancellation lifecycle", () => {
expect(request).toHaveBeenCalledWith(
"tasks.list",
expect.objectContaining({ agentId: "writer", status: ["queued", "running"] }),
{ signal: expect.any(AbortSignal) },
);
expect(request).toHaveBeenCalledWith(
"tasks.list",
expect.objectContaining({ agentId: "writer", limit: 200 }),
{ signal: expect.any(AbortSignal) },
);
});
@@ -359,7 +361,11 @@ describe("TasksPage cancellation lifecycle", () => {
const page = document.createElement("openclaw-tasks-page") as TasksPageTestElement;
page.context = createContext(source.gateway);
document.body.append(page);
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("tasks.list", expect.anything()));
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("tasks.list", expect.anything(), {
signal: expect.any(AbortSignal),
}),
);
const cancelling = page.cancelTask("task-1");
await vi.waitFor(() =>
+67 -77
View File
@@ -1,4 +1,5 @@
import { consume } from "@lit/context";
import { initialState, Task, TaskStatus } from "@lit/task";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
@@ -55,7 +56,6 @@ function taskMatchesAgentScope(task: TaskSummary, agentId: string | null): boole
type TaskRefreshEvent = NonNullable<ReturnType<typeof normalizeTaskEventPayload>>;
type TaskRefreshEventBuffer = {
generation: number;
gateway: ApplicationContext["gateway"];
client: GatewayBrowserClient;
scopeId: string | null;
@@ -68,16 +68,71 @@ class TasksPage extends OpenClawLightDomElement {
@state() private tasks: TaskSummary[] = [];
@state() private connected = false;
@state() private loading = false;
@state() private error: string | null = null;
@state() private cancellingTaskIds = new Set<string>();
private client: GatewayBrowserClient | null = null;
private loadGeneration = 0;
private operationEpoch = 0;
private observedAgentScopeId: string | null | undefined;
private gatewaySource?: ApplicationContext["gateway"];
private taskRefreshEvents: TaskRefreshEventBuffer | null = null;
private readonly listTask = new Task(this, {
autoRun: false,
// Gateway identity retires reconnect/source replacements even when they reuse a client.
args: () =>
[
this.connected ? (this.gatewaySource ?? null) : null,
this.connected ? this.client : null,
this.context?.agentSelection.state.scopeId ?? null,
] as const,
task: async ([gateway, client, scopeId], { signal }) => {
if (!gateway || !client) {
return initialState;
}
const buffer: TaskRefreshEventBuffer = {
gateway,
client,
scopeId,
events: [],
};
this.taskRefreshEvents = buffer;
const agentId = scopeId ?? undefined;
const [activePayload, recentPayload] = await Promise.all([
client.request(
"tasks.list",
{
status: ["queued", "running"],
limit: 500,
...(agentId ? { agentId } : {}),
},
{ signal },
),
client.request("tasks.list", { limit: 200, ...(agentId ? { agentId } : {}) }, { signal }),
]);
const active = normalizeTasksListResult(activePayload);
const recent = normalizeTasksListResult(recentPayload);
if (!active || !recent) {
throw new Error(t("tasksPage.invalidResponse"));
}
return { active, recent, buffer };
},
onComplete: ({ active, recent, buffer }) => {
// The active query is issued first; a same-millisecond recent page
// must win running-progress ties when a pushed event is dropped.
let tasks = mergeTaskLists(active, recent);
for (const event of buffer.events) {
tasks = applyTaskEvent(tasks, event).tasks;
}
this.tasks = tasks;
if (this.taskRefreshEvents === buffer) {
this.taskRefreshEvents = null;
}
},
onError: (error) => {
this.taskRefreshEvents = null;
this.error = formatTaskError(error, t("tasksPage.loadFailed"));
},
});
private readonly subscriptions = new SubscriptionsController(this)
.effect(
() => this.context?.gateway,
@@ -117,7 +172,6 @@ class TasksPage extends OpenClawLightDomElement {
normalizedEvent &&
normalizedEvent.action !== "restored" &&
buffer &&
buffer.generation === this.loadGeneration &&
buffer.gateway === gateway &&
buffer.client === this.client &&
buffer.scopeId === scopeId &&
@@ -195,10 +249,9 @@ class TasksPage extends OpenClawLightDomElement {
private invalidateGatewayWork() {
// Reconnects may reuse the client object; the epoch keeps pre-disconnect
// cancellation responses from mutating the replacement task snapshot.
this.loadGeneration += 1;
this.operationEpoch += 1;
this.taskRefreshEvents = null;
this.loading = false;
void this.listTask.run([null, null, null]);
this.cancellingTaskIds = new Set();
}
@@ -217,79 +270,15 @@ class TasksPage extends OpenClawLightDomElement {
);
}
private isLoadScopeCurrent(
gateway: ApplicationContext["gateway"],
client: GatewayBrowserClient,
generation: number,
): boolean {
return (
this.isConnected &&
this.connected &&
this.gatewaySource === gateway &&
this.context.gateway === gateway &&
this.client === client &&
this.loadGeneration === generation
);
}
private async refreshTasks() {
private refreshTasks(): Promise<void> {
const gateway = this.gatewaySource;
const client = this.client;
if (!gateway || this.context.gateway !== gateway || !this.connected || !client) {
return;
return Promise.resolve();
}
const generation = ++this.loadGeneration;
const scopeId = this.context.agentSelection.state.scopeId;
// Replay only events received during this exact scoped request; otherwise
// late snapshot pages can undo concurrent completions, creations, or deletes.
const taskRefreshEvents: TaskRefreshEventBuffer = {
generation,
gateway,
client,
scopeId,
events: [],
};
this.taskRefreshEvents = taskRefreshEvents;
this.loading = true;
this.error = null;
try {
const agentId = scopeId ?? undefined;
// Active tasks need their own query: the ledger pages newest-first, so a
// long-running task can hide behind newer terminal records on page one.
const [activePayload, recentPayload] = await Promise.all([
client.request("tasks.list", {
status: ["queued", "running"],
limit: 500,
...(agentId ? { agentId } : {}),
}),
client.request("tasks.list", { limit: 200, ...(agentId ? { agentId } : {}) }),
]);
const active = normalizeTasksListResult(activePayload);
const recent = normalizeTasksListResult(recentPayload);
if (!active || !recent) {
throw new Error(t("tasksPage.invalidResponse"));
}
if (this.isLoadScopeCurrent(gateway, client, generation)) {
// The active query is issued first; a same-millisecond recent page
// must win running-progress ties when a pushed event is dropped.
let tasks = mergeTaskLists(active, recent);
for (const event of taskRefreshEvents.events) {
tasks = applyTaskEvent(tasks, event).tasks;
}
this.tasks = tasks;
}
} catch (error) {
if (this.isLoadScopeCurrent(gateway, client, generation)) {
this.error = formatTaskError(error, t("tasksPage.loadFailed"));
}
} finally {
if (this.taskRefreshEvents === taskRefreshEvents) {
this.taskRefreshEvents = null;
}
if (this.isLoadScopeCurrent(gateway, client, generation)) {
this.loading = false;
}
}
return this.listTask.run([gateway, client, scopeId]);
}
private async cancelTask(taskId: string) {
@@ -319,7 +308,6 @@ class TasksPage extends OpenClawLightDomElement {
if (
event &&
buffer &&
buffer.generation === this.loadGeneration &&
buffer.gateway === gateway &&
buffer.client === client &&
buffer.scopeId === this.context.agentSelection.state.scopeId
@@ -363,10 +351,12 @@ class TasksPage extends OpenClawLightDomElement {
<button
class="btn"
type="button"
?disabled=${!this.connected || this.loading}
?disabled=${!this.connected || this.listTask.status === TaskStatus.PENDING}
@click=${() => void this.refreshTasks()}
>
${this.loading ? t("common.refreshing") : t("common.refresh")}
${this.listTask.status === TaskStatus.PENDING
? t("common.refreshing")
: t("common.refresh")}
</button>
</div>
</section>
@@ -380,7 +370,7 @@ class TasksPage extends OpenClawLightDomElement {
connected: this.connected,
// tasks.cancel needs operator.write; read-only operators get no button.
canCancel: hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
loading: this.loading,
loading: this.listTask.status === TaskStatus.PENDING,
error: this.error,
tasks: this.tasks,
cancellingTaskIds: this.cancellingTaskIds,