fix: keep dashboards current after session changes

This commit is contained in:
Shakker
2026-08-08 15:48:36 +01:00
parent 40385bb5c1
commit 9090e92f36
4 changed files with 338 additions and 14 deletions
@@ -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);
}
+79 -1
View File
@@ -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");
});
});
+44 -13
View File
@@ -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>`,
})),
});