mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
7df5834511
commit
c41ca078a6
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user