mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix: keep dashboards current after session changes
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
|
||||
import { page as dashboardsRoute } from "./route.ts";
|
||||
import type { DashboardsRouteData } from "./view.ts";
|
||||
import "./dashboards-page.ts";
|
||||
|
||||
type DashboardsPageElement = HTMLElement & {
|
||||
routeData?: DashboardsRouteData;
|
||||
updateComplete: Promise<boolean>;
|
||||
};
|
||||
|
||||
async function loadDashboards(
|
||||
context: ApplicationContext,
|
||||
options: Parameters<NonNullable<typeof dashboardsRoute.loader>>[1],
|
||||
): Promise<DashboardsRouteData> {
|
||||
return (await Promise.resolve(dashboardsRoute.loader!(context, options))) as DashboardsRouteData;
|
||||
}
|
||||
|
||||
function result(sessionRow: GatewaySessionRow): SessionsListResult {
|
||||
return {
|
||||
ts: 1,
|
||||
path: "(multiple)",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [sessionRow],
|
||||
};
|
||||
}
|
||||
|
||||
function row(key: string, displayName: string): GatewaySessionRow {
|
||||
return {
|
||||
key,
|
||||
kind: "direct",
|
||||
boardFace: "dashboard",
|
||||
displayName,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DashboardsPage", () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.setLocale("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reloads rendered rows once per canonical revision or agent scope change", async () => {
|
||||
const sessionListeners = new Set<() => void>();
|
||||
const selectionListeners = new Set<() => void>();
|
||||
let canonicalListRevision = 1;
|
||||
const selectionState = { selectedId: "main", scopeId: null as string | null };
|
||||
let element: DashboardsPageElement;
|
||||
const list = vi
|
||||
.fn<() => Promise<SessionsListResult | null>>()
|
||||
.mockResolvedValueOnce(result(row("agent:main:before", "Before")))
|
||||
.mockResolvedValueOnce(result(row("agent:main:after", "After")))
|
||||
.mockResolvedValueOnce(result(row("agent:writer:scoped", "Writer dashboard")));
|
||||
const context = {
|
||||
basePath: "",
|
||||
gateway: { snapshot: { client: {}, phase: "connected", hello: null } },
|
||||
sessions: {
|
||||
get canonicalListRevision() {
|
||||
return canonicalListRevision;
|
||||
},
|
||||
list,
|
||||
subscribe(listener: () => void) {
|
||||
sessionListeners.add(listener);
|
||||
return () => sessionListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
agentSelection: {
|
||||
state: selectionState,
|
||||
subscribe(listener: () => void) {
|
||||
selectionListeners.add(listener);
|
||||
return () => selectionListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
agents: { state: { agentsList: null } },
|
||||
revalidate: vi.fn(async () => {
|
||||
element.routeData = await loadDashboards(context, {
|
||||
...loaderOptions,
|
||||
revalidating: true,
|
||||
cause: "revalidate",
|
||||
});
|
||||
await element.updateComplete;
|
||||
}),
|
||||
} as unknown as ApplicationContext;
|
||||
if (!dashboardsRoute.loader) {
|
||||
throw new Error("dashboards route has no loader");
|
||||
}
|
||||
const loaderOptions = {
|
||||
signal: new AbortController().signal,
|
||||
shouldRun: () => true,
|
||||
revalidating: false,
|
||||
location: { pathname: "/dashboards", search: "", hash: "" },
|
||||
deps: "",
|
||||
cause: "navigation" as const,
|
||||
};
|
||||
element = document.createElement("openclaw-dashboards-page") as DashboardsPageElement;
|
||||
element.routeData = await loadDashboards(context, loaderOptions);
|
||||
const provider = createApplicationContextProvider(context);
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(context.revalidate).not.toHaveBeenCalled();
|
||||
expect(element.textContent).toContain("Before");
|
||||
|
||||
canonicalListRevision += 1;
|
||||
sessionListeners.forEach((listener) => listener());
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("After"));
|
||||
expect(list).toHaveBeenCalledTimes(2);
|
||||
expect(context.revalidate).toHaveBeenCalledTimes(1);
|
||||
|
||||
sessionListeners.forEach((listener) => listener());
|
||||
await Promise.resolve();
|
||||
expect(context.revalidate).toHaveBeenCalledTimes(1);
|
||||
|
||||
selectionState.scopeId = "writer";
|
||||
selectionListeners.forEach((listener) => listener());
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Writer dashboard"));
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
expect(context.revalidate).toHaveBeenCalledTimes(2);
|
||||
expect(list).toHaveBeenLastCalledWith({
|
||||
limit: 50,
|
||||
boardFace: "dashboard",
|
||||
archivedFilter: "all",
|
||||
agentId: "writer",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import { renderDashboards, type DashboardsRouteData } from "./view.ts";
|
||||
|
||||
class DashboardsPage extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context?: ApplicationContext;
|
||||
|
||||
@property({ attribute: false }) routeData?: DashboardsRouteData;
|
||||
|
||||
private observedSessions?: ApplicationContext["sessions"];
|
||||
private observedAgentSelection?: ApplicationContext["agentSelection"];
|
||||
private observedDependencies = "";
|
||||
private dependenciesInitialized = false;
|
||||
private readonly subscriptions = new SubscriptionsController(this)
|
||||
.effect(
|
||||
() => this.context?.sessions,
|
||||
(sessions) => {
|
||||
this.synchronizeDependencies();
|
||||
return sessions.subscribe(() => this.synchronizeDependencies());
|
||||
},
|
||||
)
|
||||
.effect(
|
||||
() => this.context?.agentSelection,
|
||||
(agentSelection) => {
|
||||
this.synchronizeDependencies();
|
||||
return agentSelection.subscribe(() => this.synchronizeDependencies());
|
||||
},
|
||||
);
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private synchronizeDependencies(): void {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const sessions = context.sessions;
|
||||
const agentSelection = context.agentSelection;
|
||||
const dependencies = `${agentSelection.state.scopeId ?? "all"}\u0000${
|
||||
sessions.canonicalListRevision
|
||||
}`;
|
||||
const sourceChanged =
|
||||
sessions !== this.observedSessions || agentSelection !== this.observedAgentSelection;
|
||||
if (
|
||||
this.dependenciesInitialized &&
|
||||
!sourceChanged &&
|
||||
dependencies === this.observedDependencies
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const shouldRevalidate =
|
||||
this.dependenciesInitialized && context.gateway.snapshot.phase === "connected";
|
||||
this.dependenciesInitialized = true;
|
||||
this.observedSessions = sessions;
|
||||
this.observedAgentSelection = agentSelection;
|
||||
this.observedDependencies = dependencies;
|
||||
if (shouldRevalidate) {
|
||||
void context.revalidate("dashboards").catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return renderDashboards(this.routeData);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-dashboards-page")) {
|
||||
customElements.define("openclaw-dashboards-page", DashboardsPage);
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import type { RouteLoaderOptions } from "@openclaw/uirouter";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionsListResult } from "../../api/types.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { page } from "./route.ts";
|
||||
import type { DashboardsRouteData } from "./view.ts";
|
||||
|
||||
const loaderOptions: RouteLoaderOptions = {
|
||||
signal: new AbortController().signal,
|
||||
@@ -14,6 +17,23 @@ const loaderOptions: RouteLoaderOptions = {
|
||||
cause: "navigation",
|
||||
};
|
||||
|
||||
function sessionsResult(key: string): SessionsListResult {
|
||||
return {
|
||||
ts: 1,
|
||||
path: "(multiple)",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [{ key, kind: "direct", boardFace: "dashboard", updatedAt: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDashboards(
|
||||
context: ApplicationContext,
|
||||
options: RouteLoaderOptions,
|
||||
): Promise<Awaited<ReturnType<NonNullable<typeof page.loader>>>> {
|
||||
return await Promise.resolve(page.loader!(context, options));
|
||||
}
|
||||
|
||||
describe("dashboards route", () => {
|
||||
it("requests the dashboard face from the server before pagination", async () => {
|
||||
const list = vi.fn(async () => null);
|
||||
@@ -28,7 +48,7 @@ describe("dashboards route", () => {
|
||||
throw new Error("dashboards route has no loader");
|
||||
}
|
||||
|
||||
await page.loader(context, loaderOptions);
|
||||
await loadDashboards(context, loaderOptions);
|
||||
|
||||
expect(list).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
@@ -36,4 +56,62 @@ describe("dashboards route", () => {
|
||||
archivedFilter: "all",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish a retired connection result while replacement hydration is pending", async () => {
|
||||
let resolveRetired!: (value: SessionsListResult | null) => void;
|
||||
const retiredResult = new Promise<SessionsListResult | null>((resolve) => {
|
||||
resolveRetired = resolve;
|
||||
});
|
||||
const clientA = {} as GatewayBrowserClient;
|
||||
const clientB = {} as GatewayBrowserClient;
|
||||
let canonicalListRevision = 1;
|
||||
let gatewaySnapshot = { client: clientA, phase: "connected", hello: null };
|
||||
const gateway = {
|
||||
get snapshot() {
|
||||
return gatewaySnapshot;
|
||||
},
|
||||
} as unknown as ApplicationContext["gateway"];
|
||||
const list = vi
|
||||
.fn<() => Promise<SessionsListResult | null>>()
|
||||
.mockImplementationOnce(() => retiredResult)
|
||||
.mockResolvedValueOnce(sessionsResult("agent:main:current"));
|
||||
const context = {
|
||||
basePath: "",
|
||||
sessions: {
|
||||
list,
|
||||
get canonicalListRevision() {
|
||||
return canonicalListRevision;
|
||||
},
|
||||
},
|
||||
agentSelection: { state: { selectedId: "main", scopeId: null } },
|
||||
agents: { state: { agentsList: null } },
|
||||
gateway,
|
||||
} as unknown as ApplicationContext;
|
||||
if (!page.loader) {
|
||||
throw new Error("dashboards route has no loader");
|
||||
}
|
||||
|
||||
const retiredAbort = new AbortController();
|
||||
let retiredSettled = false;
|
||||
const retiredLoad = loadDashboards(context, {
|
||||
...loaderOptions,
|
||||
signal: retiredAbort.signal,
|
||||
revalidating: true,
|
||||
}).finally(() => {
|
||||
retiredSettled = true;
|
||||
});
|
||||
gatewaySnapshot = { client: clientA, phase: "reconnecting", hello: null };
|
||||
resolveRetired(null);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(retiredSettled).toBe(false);
|
||||
|
||||
gatewaySnapshot = { client: clientB, phase: "connected", hello: null };
|
||||
canonicalListRevision = 2;
|
||||
retiredAbort.abort();
|
||||
await expect(retiredLoad).rejects.toMatchObject({ name: "AbortError" });
|
||||
|
||||
const current = (await loadDashboards(context, loaderOptions)) as DashboardsRouteData;
|
||||
expect(current.result?.sessions[0]?.key).toBe("agent:main:current");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,23 +7,53 @@ import { resolveSessionNavigationAgentId } from "../../lib/sessions/route-naviga
|
||||
import { resolveUiConfiguredMainKey } from "../../lib/sessions/session-key.ts";
|
||||
import type { DashboardsRouteData } from "./view.ts";
|
||||
|
||||
async function loadDashboardsRoute(context: ApplicationContext): Promise<DashboardsRouteData> {
|
||||
const result = await context.sessions
|
||||
.list({
|
||||
function waitForSupersedingNavigation(signal: AbortSignal): Promise<never> {
|
||||
const abortError = () =>
|
||||
signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException("Dashboard route load superseded", "AbortError");
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(abortError());
|
||||
}
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener("abort", () => reject(abortError()), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDashboardsRoute(
|
||||
context: ApplicationContext,
|
||||
signal: AbortSignal,
|
||||
): Promise<DashboardsRouteData> {
|
||||
const gateway = context.gateway;
|
||||
const client = gateway.snapshot.phase === "connected" ? gateway.snapshot.client : null;
|
||||
let value = null;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
value = await context.sessions.list({
|
||||
...DEFAULT_SESSION_LIST_QUERY,
|
||||
boardFace: "dashboard",
|
||||
archivedFilter: "all",
|
||||
...(context.agentSelection.state.scopeId
|
||||
? { agentId: context.agentSelection.state.scopeId }
|
||||
: {}),
|
||||
})
|
||||
.then(
|
||||
(value) => ({ value, error: null }),
|
||||
(error: unknown) => ({ value: null, error: String(error) }),
|
||||
);
|
||||
});
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
}
|
||||
if (
|
||||
client &&
|
||||
(gateway !== context.gateway ||
|
||||
gateway.snapshot.phase !== "connected" ||
|
||||
gateway.snapshot.client !== client ||
|
||||
(error === null && value === null))
|
||||
) {
|
||||
// Keep the last successful match visible until reconnect hydration starts
|
||||
// a current-connection load and the router aborts this retired request.
|
||||
return waitForSupersedingNavigation(signal);
|
||||
}
|
||||
return {
|
||||
result: result.value,
|
||||
error: result.error,
|
||||
result: value,
|
||||
error,
|
||||
basePath: context.basePath,
|
||||
fallbackAgentId: resolveSessionNavigationAgentId(context),
|
||||
mainKey: resolveUiConfiguredMainKey({
|
||||
@@ -37,10 +67,11 @@ export const page = definePage({
|
||||
...routePageSpec("dashboards"),
|
||||
loaderDeps: (context: ApplicationContext) =>
|
||||
`${context.agentSelection.state.scopeId ?? "all"}\u0000${context.sessions.canonicalListRevision}`,
|
||||
loader: (context: ApplicationContext) => loadDashboardsRoute(context),
|
||||
loader: (context: ApplicationContext, { signal }) => loadDashboardsRoute(context, signal),
|
||||
component: () =>
|
||||
import("./view.ts").then(({ renderDashboards }) => ({
|
||||
import("./dashboards-page.ts").then(() => ({
|
||||
header: true,
|
||||
render: (data: DashboardsRouteData | undefined) => html`${renderDashboards(data)}`,
|
||||
render: (data: DashboardsRouteData | undefined) =>
|
||||
html`<openclaw-dashboards-page .routeData=${data}></openclaw-dashboards-page>`,
|
||||
})),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user