diff --git a/ui/src/pages/dashboards/dashboards-page.test.ts b/ui/src/pages/dashboards/dashboards-page.test.ts new file mode 100644 index 000000000000..3bc79e0d04a8 --- /dev/null +++ b/ui/src/pages/dashboards/dashboards-page.test.ts @@ -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; +}; + +async function loadDashboards( + context: ApplicationContext, + options: Parameters>[1], +): Promise { + 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>() + .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", + }); + }); +}); diff --git a/ui/src/pages/dashboards/dashboards-page.ts b/ui/src/pages/dashboards/dashboards-page.ts new file mode 100644 index 000000000000..ab263994702f --- /dev/null +++ b/ui/src/pages/dashboards/dashboards-page.ts @@ -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); +} diff --git a/ui/src/pages/dashboards/route.test.ts b/ui/src/pages/dashboards/route.test.ts index 83773f9ef779..a890a80e1371 100644 --- a/ui/src/pages/dashboards/route.test.ts +++ b/ui/src/pages/dashboards/route.test.ts @@ -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>>> { + 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((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>() + .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"); + }); }); diff --git a/ui/src/pages/dashboards/route.ts b/ui/src/pages/dashboards/route.ts index 8dc66990963d..005ab2d9508f 100644 --- a/ui/src/pages/dashboards/route.ts +++ b/ui/src/pages/dashboards/route.ts @@ -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 { - const result = await context.sessions - .list({ +function waitForSupersedingNavigation(signal: AbortSignal): Promise { + 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 { + 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``, })), });