From deb7faf7b0668d3bc01161aff4fba0a85a65829c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 05:28:52 -0700 Subject: [PATCH] refactor(ui): harden Lit lifecycle boundaries (#102745) * refactor(ui): harden Lit lifecycle boundaries * fix(ui): clear ChatPage subscriptions before teardown * test(ui): satisfy lifecycle lint gates * chore: defer Lit lifecycle release note * fix(ui): sync raw-copy baseline * test(ui): avoid order-sensitive Sessions mock --------- Co-authored-by: Peter Steinberger --- pnpm-lock.yaml | 3 + ui/package.json | 1 + ui/src/app/app-host.test.ts | 171 ++++++ ui/src/app/app-host.ts | 332 +++++----- ui/src/app/context.test.ts | 60 ++ ui/src/app/overlays.test.ts | 159 ++++- ui/src/app/overlays.ts | 103 +++- ui/src/app/router-outlet-controller.test.ts | 239 ++++++++ ui/src/app/router-outlet-controller.ts | 273 +++++++++ ui/src/app/router-outlet.test.ts | 120 ++++ ui/src/app/router-outlet.ts | 214 ++----- ui/src/components/app-sidebar.test.ts | 137 +++++ ui/src/components/app-sidebar.ts | 103 ++-- ui/src/components/app-topbar.ts | 9 +- ui/src/components/command-palette.test.ts | 224 +++++++ ui/src/components/command-palette.ts | 94 ++- ui/src/components/connection-banner.ts | 9 +- ui/src/components/dashboard-header.ts | 9 +- ui/src/components/exec-approval.ts | 9 +- ui/src/components/file-preview-modal.test.ts | 20 + ui/src/components/file-preview-modal.ts | 16 +- ui/src/components/gateway-url-confirmation.ts | 9 +- ui/src/components/login-gate.ts | 9 +- ui/src/components/modal-dialog.test.ts | 14 + ui/src/components/modal-dialog.ts | 10 +- ui/src/components/resizable-divider.ts | 5 +- ui/src/components/session-menu.ts | 9 +- ui/src/components/settings-sidebar.ts | 13 +- .../terminal/terminal-panel.test.ts | 213 ++++++- ui/src/components/terminal/terminal-panel.ts | 330 +++++++--- ui/src/components/theme-mode-toggle.ts | 9 +- ui/src/components/tooltip.test.ts | 133 ++++ ui/src/components/tooltip.ts | 60 +- ui/src/components/update-banner.ts | 9 +- ui/src/e2e/chat-flow.e2e.test.ts | 35 +- ui/src/i18n/lib/lit-controller.test.ts | 82 +++ ui/src/i18n/lib/lit-controller.ts | 4 + ui/src/lib/agents/identity.test.ts | 52 ++ ui/src/lib/agents/identity.ts | 26 +- ui/src/lib/agents/index.test.ts | 182 +++++- ui/src/lib/agents/index.ts | 88 ++- ui/src/lib/agents/tools-effective.ts | 7 +- ui/src/lib/channels/index.test.ts | 105 +++- ui/src/lib/channels/index.ts | 146 ++++- ui/src/lib/config/index.test.ts | 140 +++++ ui/src/lib/config/index.ts | 127 +++- ui/src/lib/nodes/device-token.test.ts | 115 ++++ ui/src/lib/nodes/exec-approvals.test.ts | 39 ++ ui/src/lib/nodes/index.ts | 121 ++-- ui/src/lib/sessions/index.test.ts | 133 +++- ui/src/lib/sessions/index.ts | 273 ++++++--- ui/src/lib/skills/index.test.ts | 36 ++ ui/src/lib/skills/index.ts | 14 +- ui/src/lit/openclaw-element.test.ts | 74 +++ ui/src/lit/openclaw-element.ts | 14 + ui/src/lit/subscriptions-controller.test.ts | 214 +++++++ ui/src/lit/subscriptions-controller.ts | 149 +++++ ui/src/pages/activity/activity-page.test.ts | 73 +++ ui/src/pages/activity/activity-page.ts | 104 ++-- ui/src/pages/agents/agents-page.test.ts | 415 +++++++++++++ ui/src/pages/agents/agents-page.ts | 390 +++++++++--- ui/src/pages/agents/files.test.ts | 92 +++ ui/src/pages/agents/files.ts | 37 +- ui/src/pages/agents/route.ts | 6 +- ui/src/pages/agents/skills.ts | 19 +- ui/src/pages/channels/channels-page.test.ts | 225 +++++++ ui/src/pages/channels/channels-page.ts | 273 ++++++--- ui/src/pages/chat/chat-gateway.test.ts | 77 +++ ui/src/pages/chat/chat-history.ts | 78 ++- ui/src/pages/chat/chat-page.test.ts | 42 ++ ui/src/pages/chat/chat-page.ts | 21 +- ui/src/pages/chat/chat-pane.test.ts | 226 +++++++ ui/src/pages/chat/chat-pane.ts | 201 ++++-- ui/src/pages/chat/chat-send-timing.ts | 3 +- ui/src/pages/chat/chat-send.test.ts | 32 +- ui/src/pages/chat/chat-send.ts | 3 +- ui/src/pages/chat/chat-state.test.ts | 241 +++++++- ui/src/pages/chat/chat-state.ts | 269 ++++++-- ui/src/pages/chat/chat-view.test.ts | 32 + ui/src/pages/chat/components/chat-composer.ts | 3 + ui/src/pages/chat/components/chat-sidebar.ts | 11 +- .../pages/chat/composer-persistence.test.ts | 33 +- ui/src/pages/chat/composer-persistence.ts | 14 +- ui/src/pages/chat/performance.ts | 69 ++- ui/src/pages/chat/render-lifecycle.ts | 25 + ui/src/pages/chat/scroll.test.ts | 116 ++-- ui/src/pages/chat/scroll.ts | 275 +++++---- ui/src/pages/config/config-page.test.ts | 116 +++- ui/src/pages/config/config-page.ts | 140 +++-- ui/src/pages/cron/cron-page.test.ts | 219 +++++++ ui/src/pages/cron/cron-page.ts | 159 +++-- ui/src/pages/debug/debug-page.ts | 146 +++-- ui/src/pages/dreams/dreams-page.test.ts | 199 ++++++ ui/src/pages/dreams/dreams-page.ts | 250 ++++++-- ui/src/pages/dreams/route.ts | 13 +- .../pages/gateway-source-replacement.test.ts | 499 +++++++++++++++ ui/src/pages/instances/instances-page.ts | 60 +- ui/src/pages/logs/logs-page.test.ts | 175 ++++++ ui/src/pages/logs/logs-page.ts | 178 ++++-- ui/src/pages/nodes/nodes-page.test.ts | 159 +++++ ui/src/pages/nodes/nodes-page.ts | 141 +++-- ui/src/pages/nodes/route.ts | 11 +- ui/src/pages/overview/overview-page.test.ts | 175 ++++++ ui/src/pages/overview/overview-page.ts | 175 ++++-- ui/src/pages/plugin/plugin-page.test.ts | 210 ++++++- ui/src/pages/plugin/plugin-page.ts | 55 +- ui/src/pages/route-provenance.test.ts | 184 ++++++ ui/src/pages/sessions/route.ts | 7 +- ui/src/pages/sessions/sessions-page.test.ts | 379 ++++++++++++ ui/src/pages/sessions/sessions-page.ts | 572 +++++++++++------- .../skill-workshop-page.test.ts | 215 +++++++ .../skill-workshop/skill-workshop-page.ts | 308 +++++++--- ui/src/pages/skills/route.ts | 18 +- ui/src/pages/skills/skills-page.ts | 155 +++-- ui/src/pages/tasks/tasks-page.test.ts | 98 +++ ui/src/pages/tasks/tasks-page.ts | 191 ++++-- ui/src/pages/usage/route.ts | 23 +- ui/src/pages/usage/usage-page.ts | 72 +-- ui/src/pages/workboard/workboard-page.test.ts | 87 +++ ui/src/pages/workboard/workboard-page.ts | 106 ++-- ui/src/pages/worktrees/worktrees-page.test.ts | 229 +++++++ ui/src/pages/worktrees/worktrees-page.ts | 181 ++++-- 122 files changed, 12276 insertions(+), 2338 deletions(-) create mode 100644 ui/src/app/app-host.test.ts create mode 100644 ui/src/app/context.test.ts create mode 100644 ui/src/app/router-outlet-controller.test.ts create mode 100644 ui/src/app/router-outlet-controller.ts create mode 100644 ui/src/app/router-outlet.test.ts create mode 100644 ui/src/components/app-sidebar.test.ts create mode 100644 ui/src/components/command-palette.test.ts create mode 100644 ui/src/components/tooltip.test.ts create mode 100644 ui/src/i18n/lib/lit-controller.test.ts create mode 100644 ui/src/lib/agents/identity.test.ts create mode 100644 ui/src/lib/nodes/device-token.test.ts create mode 100644 ui/src/lit/openclaw-element.test.ts create mode 100644 ui/src/lit/openclaw-element.ts create mode 100644 ui/src/lit/subscriptions-controller.test.ts create mode 100644 ui/src/lit/subscriptions-controller.ts create mode 100644 ui/src/pages/activity/activity-page.test.ts create mode 100644 ui/src/pages/agents/agents-page.test.ts create mode 100644 ui/src/pages/agents/files.test.ts create mode 100644 ui/src/pages/channels/channels-page.test.ts create mode 100644 ui/src/pages/chat/chat-pane.test.ts create mode 100644 ui/src/pages/chat/render-lifecycle.ts create mode 100644 ui/src/pages/cron/cron-page.test.ts create mode 100644 ui/src/pages/dreams/dreams-page.test.ts create mode 100644 ui/src/pages/gateway-source-replacement.test.ts create mode 100644 ui/src/pages/logs/logs-page.test.ts create mode 100644 ui/src/pages/nodes/nodes-page.test.ts create mode 100644 ui/src/pages/overview/overview-page.test.ts create mode 100644 ui/src/pages/route-provenance.test.ts create mode 100644 ui/src/pages/sessions/sessions-page.test.ts create mode 100644 ui/src/pages/skill-workshop/skill-workshop-page.test.ts create mode 100644 ui/src/pages/tasks/tasks-page.test.ts create mode 100644 ui/src/pages/workboard/workboard-page.test.ts create mode 100644 ui/src/pages/worktrees/worktrees-page.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92809442cd7e..3157a6a6caaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1993,6 +1993,9 @@ importers: '@create-markdown/preview': specifier: 2.0.3 version: 2.0.3(shiki@4.3.0) + '@lit/context': + specifier: 1.1.6 + version: 1.1.6 '@noble/ed25519': specifier: 3.1.0 version: 3.1.0 diff --git a/ui/package.json b/ui/package.json index b0e65953d272..e4ec676abd6a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@create-markdown/preview": "2.0.3", + "@lit/context": "1.1.6", "@noble/ed25519": "3.1.0", "@openclaw/libterminal": "0.3.1", "@openclaw/media-core": "workspace:*", diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts new file mode 100644 index 000000000000..20975036d50a --- /dev/null +++ b/ui/src/app/app-host.test.ts @@ -0,0 +1,171 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { + ApplicationContext, + ApplicationGateway, + ApplicationGatewaySnapshot, +} from "./context.ts"; +import "./app-host.ts"; + +type AppLifecycleState = { + loginToken: string; + loginPassword: string; + loginShowGatewayToken: boolean; + loginShowGatewayPassword: boolean; + disconnectedCallback: () => void; + synchronizeGateway: (gateway: ApplicationGateway) => void; +}; + +type ShellInitializationState = { + routeState: { routeId?: string }; + ensureAgentsList: ( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + agents: ApplicationContext["agents"], + ) => void; + ensureRuntimeConfig: ( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + runtimeConfig: ApplicationContext["runtimeConfig"], + ) => void; +}; + +type ShellEpochState = { + navDrawerOpen: boolean; + navDrawerTrigger: HTMLElement | null; + lastWorkspaceLocation: { routeId: string; search: string } | null; + activeSessionKey: string; + agentLabel: string; + commandPaletteTarget: unknown; + agentsListClient: GatewayBrowserClient | null; + agentsListSource: ApplicationContext["agents"] | null; + sessionKeyClient: GatewayBrowserClient | null; + runtimeConfigClient: GatewayBrowserClient | null; + runtimeConfigSource: ApplicationContext["runtimeConfig"] | null; + terminalClient: GatewayBrowserClient | null; + settingsPreloadTimers: Map>; + disconnectedCallback: () => void; +}; + +describe("OpenClaw app lifecycle", () => { + it("hides revealed login credentials when the app connection epoch ends", () => { + const app = document.createElement("openclaw-app") as unknown as AppLifecycleState; + app.loginShowGatewayToken = true; + app.loginShowGatewayPassword = true; + + app.disconnectedCallback(); + + expect(app.loginShowGatewayToken).toBe(false); + expect(app.loginShowGatewayPassword).toBe(false); + }); + + it("hides revealed login credentials when the Gateway source changes", () => { + const app = document.createElement("openclaw-app") as unknown as AppLifecycleState; + const snapshot = { + client: null, + connected: false, + reconnecting: false, + lastError: null, + lastErrorCode: null, + } as ApplicationGatewaySnapshot; + const firstGateway = { + snapshot, + connection: { gatewayUrl: "ws://first.test", token: "first", password: "first-password" }, + } as ApplicationGateway; + const secondGateway = { + snapshot, + connection: { + gatewayUrl: "ws://second.test", + token: "second", + password: "second-password", + }, + } as ApplicationGateway; + app.synchronizeGateway(firstGateway); + app.loginShowGatewayToken = true; + app.loginShowGatewayPassword = true; + + app.synchronizeGateway(secondGateway); + + expect(app.loginShowGatewayToken).toBe(false); + expect(app.loginShowGatewayPassword).toBe(false); + expect(app.loginToken).toBe("second"); + expect(app.loginPassword).toBe("second-password"); + }); +}); + +describe("OpenClaw shell source initialization", () => { + it("clears retained presentation and source ownership when its context epoch ends", () => { + const shell = document.createElement("openclaw-app-shell") as unknown as ShellEpochState; + const client = {} as GatewayBrowserClient; + const agents = {} as ApplicationContext["agents"]; + const runtimeConfig = {} as ApplicationContext["runtimeConfig"]; + const trigger = document.createElement("button"); + shell.navDrawerOpen = true; + shell.navDrawerTrigger = trigger; + shell.lastWorkspaceLocation = { routeId: "overview", search: "?agent=old" }; + shell.activeSessionKey = "agent:old:main"; + shell.agentLabel = "Old agent"; + shell.commandPaletteTarget = {}; + shell.agentsListClient = client; + shell.agentsListSource = agents; + shell.sessionKeyClient = client; + shell.runtimeConfigClient = client; + shell.runtimeConfigSource = runtimeConfig; + shell.terminalClient = client; + shell.settingsPreloadTimers.set( + trigger, + globalThis.setTimeout(() => undefined, 60_000), + ); + + shell.disconnectedCallback(); + + expect(shell.navDrawerOpen).toBe(false); + expect(shell.navDrawerTrigger).toBeNull(); + expect(shell.lastWorkspaceLocation).toBeNull(); + expect(shell.activeSessionKey).toBe(""); + expect(shell.agentLabel).toBe(""); + expect(shell.commandPaletteTarget).toBeUndefined(); + expect(shell.agentsListClient).toBeNull(); + expect(shell.agentsListSource).toBeNull(); + expect(shell.sessionKeyClient).toBeNull(); + expect(shell.runtimeConfigClient).toBeNull(); + expect(shell.runtimeConfigSource).toBeNull(); + expect(shell.terminalClient).toBeNull(); + expect(shell.settingsPreloadTimers.size).toBe(0); + }); + + it("initializes replacement capabilities even when the Gateway client is unchanged", () => { + const shell = document.createElement( + "openclaw-app-shell", + ) as unknown as ShellInitializationState; + shell.routeState = { routeId: "overview" }; + const client = {} as GatewayBrowserClient; + const snapshot = { client, connected: true }; + const firstAgents = { + state: { agentsList: null }, + ensureList: vi.fn(() => Promise.resolve(null)), + } as unknown as ApplicationContext["agents"]; + const secondAgents = { + state: { agentsList: null }, + ensureList: vi.fn(() => Promise.resolve(null)), + } as unknown as ApplicationContext["agents"]; + const firstRuntimeConfig = { + ensureLoaded: vi.fn(() => Promise.resolve()), + } as unknown as ApplicationContext["runtimeConfig"]; + const secondRuntimeConfig = { + ensureLoaded: vi.fn(() => Promise.resolve()), + } as unknown as ApplicationContext["runtimeConfig"]; + + shell.ensureAgentsList(snapshot, firstAgents); + shell.ensureAgentsList(snapshot, firstAgents); + shell.ensureAgentsList(snapshot, secondAgents); + shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig); + shell.ensureRuntimeConfig(snapshot, firstRuntimeConfig); + shell.ensureRuntimeConfig(snapshot, secondRuntimeConfig); + + expect(firstAgents.ensureList).toHaveBeenCalledOnce(); + expect(secondAgents.ensureList).toHaveBeenCalledOnce(); + expect(firstRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce(); + expect(secondRuntimeConfig.ensureLoaded).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index b5e2a200f826..3896d6dbf5b6 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -1,6 +1,6 @@ import { consume, ContextProvider } from "@lit/context"; import type { RouteLocation, RouterState } from "@openclaw/uirouter"; -import { html, LitElement, nothing } from "lit"; +import { html, nothing } from "lit"; import { property, query, state } from "lit/decorators.js"; import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts"; import type { AgentsListResult } from "../api/types.ts"; @@ -31,6 +31,8 @@ import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts" import { searchForSession } from "../lib/sessions/index.ts"; import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts"; import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts"; import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts"; @@ -134,7 +136,7 @@ function isMobileNavLayout(): boolean { return globalThis.matchMedia?.("(max-width: 1100px)").matches ?? false; } -class OpenClawApp extends LitElement { +class OpenClawApp extends OpenClawLightDomElement { @state() private gatewayConnected = false; @state() private gatewayReconnecting = false; @state() private gatewayLastError: string | null = null; @@ -162,58 +164,79 @@ class OpenClawApp extends LitElement { private readonly contextProvider = new ContextProvider(this, { context: applicationContext, }); - private stopGatewaySubscription: (() => void) | undefined; - private stopConfigSubscription: (() => void) | undefined; + private readonly subscriptions = new SubscriptionsController(this); + private loginGatewaySource: ApplicationContext["gateway"] | null = null; + private loginConnectionClient: GatewayBrowserClient | null = null; - override createRenderRoot() { - return this; + constructor() { + super(); + this.subscriptions + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.synchronizeGateway(gateway), + ) + .watch( + () => (this.terminalOnly ? this.context?.config : undefined), + (config, notify) => config.subscribe(notify), + () => this.updateTerminalSurface(), + ); } override connectedCallback() { super.connectedCallback(); + this.resetLoginSensitivePresentation(); this.runtime = bootstrapApplication(); this.context = this.runtime.context; this.initialAuthPresent = hasStoredGatewayAuth(this.context.gateway.connection); this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; + // Context identity changes only across a full app-tree connection epoch; + // descendants reconnect and rebuild their controller-owned state afterward. this.contextProvider.setValue(this.context); this.syncLoginConnection(); - let gatewayClient = this.context.gateway.snapshot.client; - this.updateGatewayStatus(this.context.gateway.snapshot); - this.stopGatewaySubscription = this.context.gateway.subscribe((snapshot) => { - if (snapshot.client !== gatewayClient) { - gatewayClient = snapshot.client; - this.syncLoginConnection(); - } - this.updateGatewayStatus(snapshot); - this.updateTerminalSurface(); - }); - if (this.terminalOnly) { - // Terminal availability also depends on config.terminalEnabled, which - // can arrive after the gateway snapshot; track it for this document mode. - this.updateTerminalSurface(); - this.stopConfigSubscription = this.context.config.subscribe(() => { - this.updateTerminalSurface(); - }); - } + // The runtime is created after controller hostConnected hooks run. Ensure + // their lazy source getters bind on both the initial mount and reconnect. + this.requestUpdate(); void this.runtime.start().catch((error: unknown) => { console.error("[openclaw] application start failed", error); }); } override disconnectedCallback() { - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; - this.stopConfigSubscription?.(); - this.stopConfigSubscription = undefined; + // Stop reactive subscriptions before disposing their application sources. + this.subscriptions.clear(); this.runtime?.stop(); this.runtime = undefined; this.context = undefined; + this.loginGatewaySource = null; + this.loginConnectionClient = null; this.pendingGatewayUrl = null; + this.resetLoginSensitivePresentation(); super.disconnectedCallback(); } - private syncLoginConnection() { - const connection = this.context?.gateway.connection; + private synchronizeGateway(gateway: ApplicationContext["gateway"]) { + const sourceChanged = gateway !== this.loginGatewaySource; + if (sourceChanged) { + this.loginGatewaySource = gateway; + this.loginConnectionClient = null; + this.resetLoginSensitivePresentation(); + } + const snapshot = gateway.snapshot; + const clientChanged = snapshot.client !== this.loginConnectionClient; + if (clientChanged) { + this.loginConnectionClient = snapshot.client; + this.resetLoginSensitivePresentation(); + } + if (sourceChanged || clientChanged) { + this.syncLoginConnection(gateway); + } + this.updateGatewayStatus(snapshot); + this.updateTerminalSurface(); + } + + private syncLoginConnection(gateway = this.context?.gateway) { + const connection = gateway?.connection; if (!connection) { return; } @@ -222,6 +245,11 @@ class OpenClawApp extends LitElement { this.loginPassword = connection.password; } + private resetLoginSensitivePresentation() { + this.loginShowGatewayToken = false; + this.loginShowGatewayPassword = false; + } + private readonly updateGatewayStatus = (snapshot: { connected: boolean; reconnecting: boolean; @@ -368,10 +396,10 @@ class OpenClawApp extends LitElement { } } -class OpenClawShell extends LitElement { +class OpenClawShell extends OpenClawLightDomElement { @property({ attribute: false }) runtime?: ApplicationRuntime; @property({ attribute: false }) onboarding = false; - @consume({ context: applicationContext, subscribe: false }) + @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @state() private navCollapsed = false; @@ -406,125 +434,128 @@ class OpenClawShell extends LitElement { // chat (the app default route) when settings was the entry point. private lastWorkspaceLocation: { routeId: RouteId; search: string } | null = null; private agentsListClient: GatewayBrowserClient | null = null; + private agentsListSource: ApplicationContext["agents"] | null = null; private sessionKeyClient: GatewayBrowserClient | null = null; - private stopAgentsSubscription: (() => void) | undefined; - private stopConfigSubscription: (() => void) | undefined; - private stopGatewaySubscription: (() => void) | undefined; - private stopNavigationSubscription: (() => void) | undefined; - private stopRouteSubscription: (() => void) | undefined; - private stopOverlaySubscription: (() => void) | undefined; - private stopRuntimeConfigSubscription: (() => void) | undefined; - private stopThemeSubscription: (() => void) | undefined; + private runtimeConfigClient: GatewayBrowserClient | null = null; + private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null; + private readonly settingsPreloadTimers = new Map< + EventTarget, + ReturnType + >(); + private readonly subscriptions = new SubscriptionsController(this); - override createRenderRoot() { - return this; + constructor() { + super(); + this.subscriptions + .effect( + () => this.context, + () => () => this.resetShellEpochState(), + ) + .watch( + () => this.context?.navigation, + (navigation, notify) => navigation.subscribe(notify), + (navigation) => this.updateNavigationPreferences(navigation.snapshot), + ) + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.synchronizeGateway(gateway.snapshot), + ) + .watch( + () => this.context?.config, + (config, notify) => config.subscribe(notify), + () => { + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.updateTerminalSurface(snapshot); + } + }, + ) + .watch( + () => this.context?.theme, + (theme, notify) => theme.subscribe(notify), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + (agents) => { + this.updateAgentLabel(); + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.ensureAgentsList(snapshot, agents); + } + }, + ) + .effect( + () => this.runtime?.router, + (router) => { + this.updateRouteState(selectShellRouteState(router.getState())); + return router.subscribeSelector( + selectShellRouteState, + (routeState) => this.updateRouteState(routeState), + equalShellRouteState, + ); + }, + ) + .watch( + () => this.context?.overlays, + (overlays, notify) => overlays.subscribe(notify), + (overlays) => { + this.overlaySnapshot = overlays.snapshot; + }, + ) + .watch( + () => this.context?.runtimeConfig, + (runtimeConfig, notify) => runtimeConfig.subscribe(notify), + (runtimeConfig) => { + const snapshot = this.context?.gateway.snapshot; + if (snapshot) { + this.ensureRuntimeConfig(snapshot, runtimeConfig); + } + }, + ); } override connectedCallback() { super.connectedCallback(); - this.startSubscriptions(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.addEventListener("keydown", this.handleDocumentKeydown); window.addEventListener("resize", this.handleWindowResize); } - override updated() { - this.startSubscriptions(); - } - - private startSubscriptions() { - const runtime = this.runtime; - const context = this.context; - if ( - !runtime || - !context || - this.stopAgentsSubscription || - this.stopConfigSubscription || - this.stopGatewaySubscription || - this.stopNavigationSubscription || - this.stopRouteSubscription || - this.stopOverlaySubscription || - this.stopRuntimeConfigSubscription || - this.stopThemeSubscription - ) { - return; - } - this.updateNavigationPreferences(context.navigation.snapshot); - this.stopNavigationSubscription = context.navigation.subscribe((snapshot) => { - this.updateNavigationPreferences(snapshot); - }); - this.updateGatewaySessionKey(context.gateway.snapshot); - this.updateGatewayStatus(context.gateway.snapshot); - this.updateTerminalSurface(context.gateway.snapshot); - this.updateAgentLabel(); - this.ensureRuntimeConfig(context.gateway.snapshot); - this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { - this.updateGatewaySessionKey(snapshot); - this.updateGatewayStatus(snapshot); - this.updateTerminalSurface(snapshot); - this.updateAgentLabel(); - this.ensureAgentsList(snapshot); - this.ensureRuntimeConfig(snapshot); - }); - this.stopConfigSubscription = context.config.subscribe(() => { - this.updateTerminalSurface(context.gateway.snapshot); - }); - this.stopThemeSubscription = context.theme.subscribe(() => this.requestUpdate()); - this.stopAgentsSubscription = context.agents.subscribe(() => { - this.updateAgentLabel(); - }); - this.updateRouteState(selectShellRouteState(runtime.router.getState())); - this.stopRouteSubscription = runtime.router.subscribeSelector( - selectShellRouteState, - (routeState) => { - this.updateRouteState(routeState); - }, - equalShellRouteState, - ); - this.overlaySnapshot = context.overlays.snapshot; - this.stopOverlaySubscription = context.overlays.subscribe((snapshot) => { - this.overlaySnapshot = snapshot; - }); - this.stopRuntimeConfigSubscription = context.runtimeConfig.subscribe(() => { - // Route enablement (e.g. Workboard) derives from the config snapshot. - this.requestUpdate(); - }); - } - override disconnectedCallback() { this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.removeEventListener("keydown", this.handleDocumentKeydown); window.removeEventListener("resize", this.handleWindowResize); - this.stopAgentsSubscription?.(); - this.stopAgentsSubscription = undefined; - this.stopConfigSubscription?.(); - this.stopConfigSubscription = undefined; - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; - this.stopNavigationSubscription?.(); - this.stopNavigationSubscription = undefined; - this.stopRouteSubscription?.(); - this.stopRouteSubscription = undefined; - this.stopOverlaySubscription?.(); - this.stopOverlaySubscription = undefined; - this.stopRuntimeConfigSubscription?.(); - this.stopRuntimeConfigSubscription = undefined; - this.stopThemeSubscription?.(); - this.stopThemeSubscription = undefined; - this.agentsListClient = null; - this.sessionKeyClient = null; - this.terminalClient = null; - this.navDrawerTrigger = null; + this.resetShellEpochState(); super.disconnectedCallback(); } + private resetShellEpochState() { + this.navDrawerOpen = false; + this.navDrawerTrigger = null; + this.lastWorkspaceLocation = null; + this.activeSessionKey = ""; + this.agentLabel = ""; + this.commandPaletteTarget = undefined; + this.agentsListClient = null; + this.agentsListSource = null; + this.sessionKeyClient = null; + this.runtimeConfigClient = null; + this.runtimeConfigSource = null; + this.terminalClient = null; + for (const timer of this.settingsPreloadTimers.values()) { + globalThis.clearTimeout(timer); + } + this.settingsPreloadTimers.clear(); + } + private readonly handleThemeChange = (event: CustomEvent) => { const context = this.context; if (!context) { return; } context.theme.setMode(event.detail.mode, event.detail.element); - this.requestUpdate(); }; private chatNavigationOptions(options?: ApplicationNavigationOptions) { @@ -709,6 +740,15 @@ class OpenClawShell extends LitElement { this.requestUpdate(); }; + private synchronizeGateway(snapshot: ApplicationContext["gateway"]["snapshot"]) { + this.updateGatewaySessionKey(snapshot); + this.updateGatewayStatus(snapshot); + this.updateTerminalSurface(snapshot); + this.updateAgentLabel(); + this.ensureAgentsList(snapshot); + this.ensureRuntimeConfig(snapshot); + } + private readonly updateGatewayStatus = (snapshot: { connected: boolean; lastError: string | null; @@ -731,15 +771,28 @@ class OpenClawShell extends LitElement { ); } - private ensureRuntimeConfig(snapshot: { - client: GatewayBrowserClient | null; - connected: boolean; - }) { + private ensureRuntimeConfig( + snapshot: { + client: GatewayBrowserClient | null; + connected: boolean; + }, + runtimeConfig = this.context?.runtimeConfig, + ) { // The sidebar hides config-gated routes (Workboard), so the snapshot must // load eagerly instead of waiting for a page that happens to fetch it. - if (snapshot.connected && snapshot.client) { - void this.context?.runtimeConfig.ensureLoaded(); + if (!snapshot.connected || !snapshot.client || !runtimeConfig) { + this.runtimeConfigClient = null; + return; } + if ( + this.runtimeConfigClient === snapshot.client && + this.runtimeConfigSource === runtimeConfig + ) { + return; + } + this.runtimeConfigClient = snapshot.client; + this.runtimeConfigSource = runtimeConfig; + void runtimeConfig.ensureLoaded(); } private enabledRouteIds(): readonly RouteId[] { @@ -748,20 +801,24 @@ class OpenClawShell extends LitElement { : ROUTE_IDS_WITHOUT_WORKBOARD; } - private ensureAgentsList(snapshot: { client: GatewayBrowserClient | null; connected: boolean }) { + private ensureAgentsList( + snapshot: { client: GatewayBrowserClient | null; connected: boolean }, + agents = this.context?.agents, + ) { if (!snapshot.connected || !snapshot.client) { this.agentsListClient = null; return; } const routeId = this.routeState.routeId; - if (!routeId || routeId === "chat" || this.context?.agents.state.agentsList) { + if (!agents || !routeId || routeId === "chat" || agents.state.agentsList) { return; } - if (this.agentsListClient === snapshot.client) { + if (this.agentsListClient === snapshot.client && this.agentsListSource === agents) { return; } this.agentsListClient = snapshot.client; - void this.context?.agents.ensureList(); + this.agentsListSource = agents; + void agents.ensureList(); } private updateGatewaySessionKey(snapshot: { @@ -891,6 +948,7 @@ class OpenClawShell extends LitElement { onExit: () => this.exitSettings(), onNavigate: (routeId) => this.navigate(routeId), onPreload: (routeId) => context.preload(routeId), + preloadTimers: this.settingsPreloadTimers, }) : html`) { + this.contextProvider.setValue(context); + } +} + +class TestApplicationContextConsumer extends LitElement { + @consume({ context: applicationContext, subscribe: true }) + context?: ApplicationContext; +} + +if (!customElements.get(PROVIDER_ELEMENT_NAME)) { + customElements.define(PROVIDER_ELEMENT_NAME, TestApplicationContextProvider); +} +if (!customElements.get(CONSUMER_ELEMENT_NAME)) { + customElements.define(CONSUMER_ELEMENT_NAME, TestApplicationContextConsumer); +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("application context consumption", () => { + it("rebinds a retained consumer after its provider value changes while disconnected", async () => { + const initialContext = { basePath: "/initial" } as ApplicationContext; + const replacementContext = { basePath: "/replacement" } as ApplicationContext; + const provider = document.createElement( + PROVIDER_ELEMENT_NAME, + ) as TestApplicationContextProvider; + const consumer = document.createElement( + CONSUMER_ELEMENT_NAME, + ) as TestApplicationContextConsumer; + + document.body.append(provider); + provider.setContext(initialContext); + provider.append(consumer); + await consumer.updateComplete; + expect(consumer.context).toBe(initialContext); + + consumer.remove(); + provider.setContext(replacementContext); + provider.append(consumer); + await consumer.updateComplete; + + expect(consumer.context).toBe(replacementContext); + }); +}); diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index b5ba79f81fc0..1d440a4f2d59 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -5,6 +5,7 @@ import type { ApplicationGateway, ApplicationGatewaySnapshot } from "./gateway.t import { createApplicationOverlays } from "./overlays.ts"; type RequestFn = (method: string, params?: unknown) => Promise; +const VERIFICATION_POLL_MS = 250; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -25,11 +26,14 @@ function approval(id: string, createdAtMs: number) { }; } -function createGatewayHarness(initialClient: GatewayBrowserClient) { +function createGatewayHarness( + initialClient: GatewayBrowserClient | null, + initialConnected = initialClient !== null, +) { let snapshot: ApplicationGatewaySnapshot = { assistantAgentId: "main", client: initialClient, - connected: true, + connected: initialConnected, reconnecting: false, hello: null, lastError: null, @@ -85,7 +89,59 @@ function client(request: RequestFn): GatewayBrowserClient { return { request } as unknown as GatewayBrowserClient; } +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe("application approval overlays", () => { + it("reloads pending approvals for each connected epoch", async () => { + const firstList = deferred(); + const reconnectedList = deferred(); + let execListRequests = 0; + const request = vi.fn((method) => { + if (method !== "exec.approval.list") { + return Promise.resolve([]); + } + execListRequests += 1; + return execListRequests === 1 ? firstList.promise : reconnectedList.promise; + }); + const gatewayClient = client(request); + const harness = createGatewayHarness(null, false); + const overlays = createApplicationOverlays(harness.gateway); + + harness.update({ client: gatewayClient, connected: false }); + await flushMicrotasks(); + expect(request).not.toHaveBeenCalled(); + + harness.update({ connected: true }); + await flushMicrotasks(); + expect(execListRequests).toBe(1); + expect(request).toHaveBeenCalledWith("exec.approval.list", {}); + expect(request).toHaveBeenCalledWith("plugin.approval.list", {}); + + harness.update({ connected: false }); + expect(overlays.snapshot.approvalQueue).toEqual([]); + harness.update({ connected: true }); + await flushMicrotasks(); + expect(execListRequests).toBe(2); + + reconnectedList.resolve([approval("approval-reconnected", 2_000)]); + await vi.waitFor(() => { + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([ + "approval-reconnected", + ]); + }); + + firstList.resolve([approval("approval-stale", 1_000)]); + await flushMicrotasks(); + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual([ + "approval-reconnected", + ]); + overlays.dispose(); + }); + it("does not attach an older resolve failure to a newer approval", async () => { const resolveAttempt = deferred(); const request = vi.fn((method) => @@ -142,6 +198,47 @@ describe("application approval overlays", () => { expect(overlays.snapshot.approvalQueue).toEqual([]); overlays.dispose(); }); + + it("does not dismiss a new approval when an old same-client decision settles", async () => { + const oldResolve = deferred(); + const request = vi.fn((method) => + method.endsWith(".list") ? Promise.resolve([]) : oldResolve.promise, + ); + const gatewayClient = client(request); + const harness = createGatewayHarness(gatewayClient); + const overlays = createApplicationOverlays(harness.gateway); + + harness.emitApproval("approval-old", 1_000); + const oldDecision = overlays.decideApproval("allow-once"); + harness.update({ connected: false }); + harness.update({ connected: true }); + await flushMicrotasks(); + harness.emitApproval("approval-new", 2_000); + + oldResolve.resolve({ ok: true }); + await oldDecision; + + expect(overlays.snapshot.approvalQueue.map((entry) => entry.id)).toEqual(["approval-new"]); + expect(overlays.snapshot.approvalBusy).toBe(false); + overlays.dispose(); + }); + + it("ignores a decision that settles after disposal", async () => { + const resolveAttempt = deferred(); + const request = vi.fn((method) => + method.endsWith(".list") ? Promise.resolve([]) : resolveAttempt.promise, + ); + const harness = createGatewayHarness(client(request)); + const overlays = createApplicationOverlays(harness.gateway); + + harness.emitApproval("approval-active", 1_000); + const decision = overlays.decideApproval("allow-once"); + overlays.dispose(); + resolveAttempt.reject(new Error("disposed")); + await decision; + + expect(overlays.snapshot.approvalError).toBeNull(); + }); }); describe("application update overlays", () => { @@ -164,4 +261,62 @@ describe("application update overlays", () => { expect(overlays.snapshot.updateRunning).toBe(false); overlays.dispose(); }); + + it("verifies on reconnect and survives updates within the connected epoch", async () => { + vi.useFakeTimers(); + let statusRequests = 0; + const request = vi.fn((method) => { + if (method.endsWith(".list")) { + return Promise.resolve([]); + } + if (method === "update.run") { + return Promise.resolve({ + ok: true, + result: { status: "ok", after: { version: "2.0.0" } }, + }); + } + if (method === "update.status") { + statusRequests += 1; + return Promise.resolve( + statusRequests === 1 + ? { + sentinel: { + kind: "update", + status: "skipped", + stats: { reason: "restart-health-pending" }, + }, + } + : { + sentinel: { + kind: "update", + status: "ok", + stats: { after: { version: "2.0.0" } }, + }, + }, + ); + } + return Promise.resolve({}); + }); + const gatewayClient = client(request); + const harness = createGatewayHarness(gatewayClient); + const overlays = createApplicationOverlays(harness.gateway); + + try { + await overlays.runUpdate(); + harness.update({ connected: false }); + harness.update({ connected: true }); + await flushMicrotasks(); + expect(statusRequests).toBe(1); + + harness.update({ sessionKey: "agent:main:next" }); + await vi.advanceTimersByTimeAsync(VERIFICATION_POLL_MS); + await flushMicrotasks(); + + expect(statusRequests).toBe(2); + expect(overlays.snapshot.updateStatusBanner).toBeNull(); + } finally { + overlays.dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 2e264b363847..9cfe7d56b9d4 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -194,6 +194,11 @@ type UpdateRunResponse = { restart?: { coalesced?: boolean } | null; }; +type UpdateVerificationWait = { + timer: ReturnType; + resolve: (active: boolean) => void; +}; + export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays { let snapshot: ApplicationOverlaySnapshot = { updateAvailable: null, @@ -211,13 +216,21 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat const listeners = new Set<(next: ApplicationOverlaySnapshot) => void>(); let disposed = false; let activeClient = gateway.snapshot.client; + // A Gateway client survives transport retries; the disconnected boundary + // still starts a new source epoch whose pending server state must be replayed. + let connectedSource: NonNullable | null = null; + let connectedEpoch = 0; let pendingUpdateExpectedVersion: string | null = null; let pendingUpdateHandoff = false; let updateRunGeneration = 0; let updateVerificationGeneration = 0; - let updateVerificationTimer: ReturnType | null = null; + let updateVerificationWait: UpdateVerificationWait | null = null; let devicePairPendingCountGeneration = 0; - let approvalDecision: { client: NonNullable; id: string } | null = null; + let approvalDecision: { + client: NonNullable; + epoch: number; + id: string; + } | null = null; const devicePairSetupState: DevicePairSetupState & { pendingCount: number } = { client: gateway.snapshot.client, connected: gateway.snapshot.connected, @@ -291,9 +304,13 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat publish(); }; - const refreshApprovals = async (client: NonNullable) => { + const refreshApprovals = async ( + client: NonNullable, + epoch = connectedEpoch, + ) => { const applied = await refreshPendingApprovalQueue(promptState, { - isCurrentClient: (requestClient) => requestClient === client && isCurrentClient(client), + isCurrentClient: (requestClient) => + requestClient === client && epoch === connectedEpoch && isCurrentClient(client), }); if (applied && !disposed) { publish(); @@ -305,26 +322,40 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat publish(); }; + const settleUpdateVerificationWait = (active: boolean) => { + const wait = updateVerificationWait; + if (!wait) { + return; + } + updateVerificationWait = null; + globalThis.clearTimeout(wait.timer); + wait.resolve(active); + }; + const cancelUpdateVerification = () => { updateVerificationGeneration += 1; - if (updateVerificationTimer !== null) { - globalThis.clearTimeout(updateVerificationTimer); - updateVerificationTimer = null; - } + settleUpdateVerificationWait(false); }; const waitForUpdateVerification = (delayMs: number, generation: number) => new Promise((resolve) => { + // Verification loops are serialized, but settling a prior wait keeps a + // future refactor from stranding its continuation behind a replaced timer. + settleUpdateVerificationWait(false); const timer = globalThis.setTimeout(() => { - if (updateVerificationTimer === timer) { - updateVerificationTimer = null; + if (updateVerificationWait?.timer !== timer) { + return; } + updateVerificationWait = null; resolve(generation === updateVerificationGeneration && !disposed); }, delayMs); - updateVerificationTimer = timer; + updateVerificationWait = { timer, resolve }; }); - const verifyPendingUpdateVersion = async (client: NonNullable) => { + const verifyPendingUpdateVersion = async ( + client: NonNullable, + epoch: number, + ) => { const generation = updateVerificationGeneration; const expectedVersion = pendingUpdateExpectedVersion?.trim() || null; const pendingHandoff = pendingUpdateHandoff; @@ -333,6 +364,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } const isCurrentVerification = () => generation === updateVerificationGeneration && + epoch === connectedEpoch && !disposed && activeClient === client && gateway.snapshot.client === client && @@ -405,14 +437,20 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat ); }; - const stopGateway = gateway.subscribe((next) => { - updateRunGeneration += 1; - cancelUpdateVerification(); + const synchronizeGateway = (next: ApplicationGateway["snapshot"]) => { const previousClient = activeClient; + const previousConnectedSource = connectedSource; + const nextConnectedSource = next.connected ? next.client : null; + const connectedSourceChanged = previousConnectedSource !== nextConnectedSource; activeClient = next.client; + connectedSource = nextConnectedSource; promptState.client = next.client; devicePairSetupState.client = next.client; devicePairSetupState.connected = next.connected; + if (connectedSourceChanged) { + updateRunGeneration += 1; + cancelUpdateVerification(); + } if (previousClient !== next.client || !next.connected) { approvalDecision = null; devicePairPendingCountGeneration += 1; @@ -432,15 +470,15 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat return; } snapshot = { ...snapshot, updateAvailable: readUpdateAvailable(next.hello) }; - if (previousClient !== next.client) { - void refreshApprovals(next.client); - if (next.client) { - void verifyPendingUpdateVersion(next.client); - } - } else { - publish(); + publish(); + if (connectedSourceChanged) { + connectedEpoch += 1; + const epoch = connectedEpoch; + void refreshApprovals(next.client, epoch); + void verifyPendingUpdateVersion(next.client, epoch); } - }); + }; + const stopGateway = gateway.subscribe(synchronizeGateway); const stopEvents = gateway.subscribeEvents((event) => { if (disposed || !isGatewayEvent(event)) { @@ -480,6 +518,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } } }); + synchronizeGateway(gateway.snapshot); return { get snapshot() { @@ -584,30 +623,35 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } promptState.execApprovalBusy = true; promptState.execApprovalError = null; - const operation = { client, id: active.id }; + const operation = { client, epoch: connectedEpoch, id: active.id }; approvalDecision = operation; + const isCurrentOperation = () => + approvalDecision === operation && + operation.epoch === connectedEpoch && + isCurrentClient(operation.client); publish(); try { const method = active.kind === "plugin" ? "plugin.approval.resolve" : "exec.approval.resolve"; await client.request(method, { id: active.id, decision }); - if (!isCurrentClient(client)) { + if (!isCurrentOperation()) { return; } dismissExecApprovalPrompt(promptState, active.id); } catch (error) { if (isStaleApprovalResolutionError(error)) { - if (!isCurrentClient(client)) { + if (!isCurrentOperation()) { return; } dismissExecApprovalPrompt(promptState, active.id); const currentClient = activeClient; - if (currentClient && isCurrentClient(currentClient)) { - await refreshApprovals(currentClient); + const epoch = connectedEpoch; + if (currentClient && isCurrentOperation()) { + await refreshApprovals(currentClient, epoch); } return; } - if (isCurrentClient(client) && promptState.execApprovalQueue[0]?.id === active.id) { + if (isCurrentOperation() && promptState.execApprovalQueue[0]?.id === active.id) { promptState.execApprovalError = `Approval failed: ${error instanceof Error ? error.message : String(error)}`; } } finally { @@ -653,6 +697,7 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat }, dispose() { disposed = true; + approvalDecision = null; updateRunGeneration += 1; devicePairPendingCountGeneration += 1; cancelUpdateVerification(); diff --git a/ui/src/app/router-outlet-controller.test.ts b/ui/src/app/router-outlet-controller.test.ts new file mode 100644 index 000000000000..3d0d1b668b6d --- /dev/null +++ b/ui/src/app/router-outlet-controller.test.ts @@ -0,0 +1,239 @@ +import { createRouter, definePage, type RouteLocation } from "@openclaw/uirouter"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RouterOutletController, selectRenderedRouteMatch } from "./router-outlet-controller.ts"; + +type RouteId = "first" | "second"; +type TestContext = { label: string }; +type TestModule = { render: (data: TestData | undefined) => unknown }; +type TestData = { label: string }; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function location(pathname: string): RouteLocation { + return { pathname, search: "", hash: "" }; +} + +function module(label: string): TestModule { + return { render: () => label }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("RouterOutletController pending presentation", () => { + it("delays a cold-start fallback until the route has been pending for one second", async () => { + vi.useFakeTimers(); + const routeModule = deferred(); + const routeData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => routeModule.promise, + loader: () => routeData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + + const navigation = router.navigate("first", { label: "test" }); + expect(controller.snapshot.pending?.routeId).toBe("first"); + expect(controller.snapshot.showPending).toBe(false); + + await vi.advanceTimersByTimeAsync(999); + expect(controller.snapshot.showPending).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(controller.snapshot.showPending).toBe(true); + + routeModule.resolve(module("first")); + routeData.resolve({ label: "loaded" }); + await navigation; + expect(controller.snapshot.showPending).toBe(false); + controller.disconnect(); + router.stop(); + }); + + it("keeps active content while the next route module is cold", async () => { + vi.useFakeTimers(); + const secondModule = deferred(); + const secondData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => module("first"), + loader: () => ({ label: "first" }), + }), + definePage({ + id: "second", + path: "/second", + component: () => secondModule.promise, + loader: () => secondData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + await router.navigate("first", { label: "test" }); + + const navigation = router.navigate("second", { label: "test" }); + expect( + selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId, + ).toBe("first"); + await vi.advanceTimersByTimeAsync(2_000); + expect(controller.snapshot.showPending).toBe(false); + + secondModule.resolve(module("second")); + await flushPromises(); + expect( + selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId, + ).toBe("second"); + expect(controller.snapshot.active?.data).toBeUndefined(); + + secondData.resolve({ label: "second" }); + await navigation; + controller.disconnect(); + router.stop(); + }); + + it("restarts a canceled pending delay after reconnect", async () => { + vi.useFakeTimers(); + const routeModule = deferred(); + const routeData = deferred(); + const router = createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => routeModule.promise, + loader: () => routeData.promise, + }), + ], + }); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router }); + controller.connect(); + const navigation = router.navigate("first", { label: "test" }); + + await vi.advanceTimersByTimeAsync(500); + controller.disconnect(); + await vi.advanceTimersByTimeAsync(1_000); + expect(controller.snapshot.showPending).toBe(false); + + controller.connect(); + await vi.advanceTimersByTimeAsync(999); + expect(controller.snapshot.showPending).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(controller.snapshot.showPending).toBe(true); + + routeModule.resolve(module("first")); + routeData.resolve({ label: "loaded" }); + await navigation; + controller.disconnect(); + router.stop(); + }); +}); + +describe("RouterOutletController not-found boundary", () => { + function createTestRouter() { + return createRouter({ + routes: [ + definePage({ + id: "first", + path: "/first", + component: () => module("first"), + loader: () => ({ label: "first" }), + }), + ], + }); + } + + it("notifies once for an unmatched location", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + await router.navigateLocation(location("/missing"), { label: "test" }); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + + controller.setInputs({ router, onNotFound }); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + controller.disconnect(); + router.stop(); + }); + + it("suppresses a queued fallback after the router recovers", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + const missing = router.navigateLocation(location("/missing"), { label: "test" }); + const recovery = router.navigate("first", { label: "test" }); + await Promise.all([missing, recovery]); + await flushPromises(); + expect(onNotFound).not.toHaveBeenCalled(); + controller.disconnect(); + router.stop(); + }); + + it("cancels the queued fallback on disconnect and re-evaluates it on reconnect", async () => { + const router = createTestRouter(); + const onNotFound = vi.fn(); + const controller = new RouterOutletController( + vi.fn(), + ); + controller.setInputs({ router, onNotFound }); + controller.connect(); + + const missing = router.navigateLocation(location("/missing"), { label: "test" }); + controller.disconnect(); + await missing; + await flushPromises(); + expect(onNotFound).not.toHaveBeenCalled(); + + controller.connect(); + await flushPromises(); + expect(onNotFound).toHaveBeenCalledTimes(1); + controller.disconnect(); + router.stop(); + }); +}); diff --git a/ui/src/app/router-outlet-controller.ts b/ui/src/app/router-outlet-controller.ts new file mode 100644 index 000000000000..398cbb7f08b5 --- /dev/null +++ b/ui/src/app/router-outlet-controller.ts @@ -0,0 +1,273 @@ +import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; + +const DEFAULT_PENDING_DELAY_MS = 1_000; + +type RouterOutletStateSlice< + TRouteId extends string = string, + TModule = unknown, + TData = unknown, +> = { + status: RouterState["status"]; + active: RouteMatch | undefined; + pending: RouteMatch | undefined; +}; + +export type RouterOutletSnapshot< + TRouteId extends string = string, + TModule = unknown, + TData = unknown, +> = RouterOutletStateSlice & { + showPending: boolean; +}; + +type RouterOutletInputs = { + router?: Router; + onNotFound?: () => void; +}; + +type RouterOutletControllerOptions = { + pendingDelayMs?: number; +}; + +export function selectRenderedRouteMatch( + active: RouteMatch | undefined, + pending: RouteMatch | undefined, +): RouteMatch | undefined { + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + return coldPending && active ? active : (pending ?? active); +} + +function selectRouterOutletState( + state: RouterState, +): RouterOutletStateSlice { + return { + status: state.status, + active: state.matches[0], + pending: state.pendingMatches[0], + }; +} + +function equalRouterOutletState( + previous: RouterOutletStateSlice, + next: RouterOutletStateSlice, +): boolean { + return ( + previous.status === next.status && + previous.active === next.active && + previous.pending === next.pending + ); +} + +function idleSnapshot(): RouterOutletSnapshot< + TRouteId, + TModule, + TData +> { + return { + status: "idle", + active: undefined, + pending: undefined, + showPending: false, + }; +} + +/** + * Owns route-presentation timing and effects without depending on a renderer. + * Render adapters provide invalidation and bind the controller to their own + * connection lifecycle. + */ +export class RouterOutletController< + TRouteId extends string = string, + TLoadContext = unknown, + TModule = unknown, + TData = unknown, +> { + private router?: Router; + private onNotFound?: () => void; + private connected = false; + private unsubscribe?: () => void; + private selection: RouterOutletStateSlice = idleSnapshot(); + private snapshotValue: RouterOutletSnapshot = idleSnapshot(); + private pendingMatchId?: string; + private pendingTimer?: ReturnType; + private showPending = false; + private notFoundActive = false; + private notFoundQueued = false; + private notFoundGeneration = 0; + private readonly pendingDelayMs: number; + + constructor( + private readonly invalidate: () => void, + options: RouterOutletControllerOptions = {}, + ) { + this.pendingDelayMs = options.pendingDelayMs ?? DEFAULT_PENDING_DELAY_MS; + } + + get snapshot(): RouterOutletSnapshot { + return this.snapshotValue; + } + + setInputs(inputs: RouterOutletInputs): void { + this.onNotFound = inputs.onNotFound; + if (this.router === inputs.router) { + return; + } + + this.detachSource(); + this.router = inputs.router; + if (this.connected) { + this.attachSource(); + return; + } + const selection = inputs.router + ? selectRouterOutletState(inputs.router.getState()) + : idleSnapshot(); + this.selection = selection; + this.publish({ ...selection, showPending: false }); + } + + connect(): void { + if (this.connected) { + return; + } + this.connected = true; + this.attachSource(false); + // A disconnected host may have retained DOM for an older snapshot. Always + // reconcile once on reconnect, even when the router state stayed stable. + this.invalidate(); + } + + disconnect(): void { + if (!this.connected) { + return; + } + this.connected = false; + this.detachSource(); + } + + private attachSource(notify = true): void { + const router = this.router; + if (!router || this.unsubscribe) { + return; + } + this.applySelection(selectRouterOutletState(router.getState()), notify); + this.unsubscribe = router.subscribeSelector( + selectRouterOutletState, + (selection) => this.applySelection(selection), + equalRouterOutletState, + ); + } + + private detachSource(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.clearPendingTimer(); + this.pendingMatchId = undefined; + this.showPending = false; + this.cancelNotFoundEffect(); + } + + private applySelection( + selection: RouterOutletStateSlice, + notify = true, + ): void { + this.selection = selection; + const pending = selection.pending; + const coldPending = + pending?.status === "pending" && pending.module === undefined && pending.error === undefined; + const needsPendingFallback = coldPending && !selection.active; + if (!needsPendingFallback) { + this.clearPendingTimer(); + this.pendingMatchId = undefined; + this.showPending = false; + } else if (this.pendingMatchId !== pending.id) { + this.clearPendingTimer(); + this.pendingMatchId = pending.id; + this.showPending = false; + this.schedulePendingFallback(pending.id); + } else if (this.connected && !this.showPending && this.pendingTimer === undefined) { + this.schedulePendingFallback(pending.id); + } + + this.publish({ ...selection, showPending: this.showPending }, notify); + this.updateNotFoundEffect(selection.status); + } + + private schedulePendingFallback(matchId: string): void { + if (!this.connected) { + return; + } + this.pendingTimer = globalThis.setTimeout(() => { + this.pendingTimer = undefined; + const pending = this.selection.pending; + const stillCold = + pending?.id === matchId && + pending.status === "pending" && + pending.module === undefined && + pending.error === undefined && + !this.selection.active; + if (!this.connected || this.pendingMatchId !== matchId || !stillCold) { + return; + } + this.showPending = true; + this.publish({ ...this.selection, showPending: true }); + }, this.pendingDelayMs); + } + + private updateNotFoundEffect(status: RouterOutletStateSlice["status"]): void { + if (status !== "notFound") { + if (this.notFoundActive || this.notFoundQueued) { + this.cancelNotFoundEffect(); + } + return; + } + if (!this.connected || this.notFoundActive) { + return; + } + + this.notFoundActive = true; + this.notFoundQueued = true; + const generation = ++this.notFoundGeneration; + queueMicrotask(() => { + if ( + !this.connected || + generation !== this.notFoundGeneration || + this.selection.status !== "notFound" + ) { + return; + } + this.notFoundQueued = false; + this.onNotFound?.(); + }); + } + + private cancelNotFoundEffect(): void { + this.notFoundGeneration += 1; + this.notFoundActive = false; + this.notFoundQueued = false; + } + + private publish(snapshot: RouterOutletSnapshot, notify = true): void { + const previous = this.snapshotValue; + if ( + previous.status === snapshot.status && + previous.active === snapshot.active && + previous.pending === snapshot.pending && + previous.showPending === snapshot.showPending + ) { + return; + } + this.snapshotValue = snapshot; + if (notify && this.connected) { + this.invalidate(); + } + } + + private clearPendingTimer(): void { + if (this.pendingTimer !== undefined) { + globalThis.clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts new file mode 100644 index 000000000000..dee222ba1a0a --- /dev/null +++ b/ui/src/app/router-outlet.test.ts @@ -0,0 +1,120 @@ +import { createRouter, definePage, type Router } from "@openclaw/uirouter"; +import { html, type LitElement } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import "./router-outlet.ts"; + +type RouteId = "page"; +type TestContext = { label: string }; +type TestData = { label: string }; +type TestModule = { render: (data: TestData | undefined) => unknown }; +type TestRouter = Router; +type RouterOutletElement = LitElement & { + router?: TestRouter; + retryContext?: TestContext; + onNotFound?: () => void; +}; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function createOutlet(router: TestRouter, context: TestContext): RouterOutletElement { + const outlet = document.createElement("openclaw-router-outlet") as RouterOutletElement; + outlet.router = router; + outlet.retryContext = context; + document.body.append(outlet); + return outlet; +} + +async function settleOutlet(outlet: RouterOutletElement): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + await Promise.resolve(); + await outlet.updateComplete; + } +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("openclaw-router-outlet", () => { + it("renders route data through the public custom-element boundary", async () => { + const context = { label: "loaded" }; + const router = createRouter({ + routes: [ + definePage({ + id: "page", + path: "/page", + component: () => ({ + render: (data: TestData | undefined) => + html`
${data?.label}
`, + }), + loader: (loadContext) => ({ label: loadContext.label }), + }), + ], + }); + const outlet = createOutlet(router, context); + + await router.navigate("page", context); + await settleOutlet(outlet); + + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("loaded"); + outlet.remove(); + router.stop(); + }); + + it("keeps a loaded route visible with an error and retries through the latest context", async () => { + const firstLoad = deferred(); + let loadCount = 0; + const router = createRouter({ + routes: [ + definePage({ + id: "page", + path: "/page", + component: () => ({ + render: (data: TestData | undefined) => + html`
${data?.label ?? "pending"}
`, + }), + loader: (context) => { + loadCount += 1; + return loadCount === 1 ? firstLoad.promise : { label: context.label }; + }, + }), + ], + }); + const initialContext = { label: "initial" }; + const retryContext = { label: "retried" }; + const outlet = createOutlet(router, initialContext); + const navigation = router.navigate("page", initialContext); + await settleOutlet(outlet); + firstLoad.reject(new Error("load failed")); + await expect(navigation).rejects.toThrow("load failed"); + await settleOutlet(outlet); + + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("pending"); + expect(outlet.querySelector('[role="alert"]')?.textContent).toContain("load failed"); + + outlet.retryContext = retryContext; + await outlet.updateComplete; + outlet.querySelector("button")?.click(); + await settleOutlet(outlet); + + expect(loadCount).toBe(2); + expect(outlet.querySelector('[data-testid="route-page"]')?.textContent).toBe("retried"); + expect(outlet.querySelector('[role="alert"]')).toBeNull(); + outlet.remove(); + router.stop(); + }); +}); diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts index 326cccb92efb..0bbc2153367a 100644 --- a/ui/src/app/router-outlet.ts +++ b/ui/src/app/router-outlet.ts @@ -1,11 +1,16 @@ -import type { RouteMatch, Router, RouterState } from "@openclaw/uirouter"; -import { html, LitElement, nothing } from "lit"; -import { AsyncDirective } from "lit/async-directive.js"; +import type { Router } from "@openclaw/uirouter"; +import { html, nothing } from "lit"; +import type { ReactiveController, ReactiveControllerHost } from "lit"; import { property } from "lit/decorators.js"; -import { directive } from "lit/directive.js"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { + RouterOutletController, + selectRenderedRouteMatch, + type RouterOutletSnapshot, +} from "./router-outlet-controller.ts"; -const PENDING_UI_DELAY_MS = 1_000; +export { selectRenderedRouteMatch } from "./router-outlet-controller.ts"; type RenderableModule = { render: (data: TData | undefined) => unknown; @@ -15,48 +20,6 @@ type RouterOutletOptions = { retryContext?: TLoadContext; }; -type RouterOutletBoundaryOptions = { - onNotFound?: () => void; -}; - -type RouterOutletSelection = { - status: RouterState["status"]; - active: RouteMatch | undefined; - pending: RouteMatch | undefined; - showPending: boolean; -}; - -export function selectRenderedRouteMatch( - active: RouteMatch | undefined, - pending: RouteMatch | undefined, -): RouteMatch | undefined { - const coldPending = - pending?.status === "pending" && pending.module === undefined && pending.error === undefined; - return coldPending && active ? active : (pending ?? active); -} - -function selectRouterOutletState( - state: RouterState, -): RouterOutletSelection { - return { - status: state.status, - active: state.matches[0], - pending: state.pendingMatches[0], - showPending: false, - }; -} - -function equalRouterOutletState( - previous: RouterOutletSelection, - next: RouterOutletSelection, -): boolean { - return ( - previous.status === next.status && - previous.active === next.active && - previous.pending === next.pending - ); -} - function isRenderableModule(module: unknown): module is RenderableModule { return ( typeof module === "object" && @@ -113,7 +76,7 @@ function renderError( function renderRouterOutlet( router: Router, - selection: RouterOutletSelection, + selection: RouterOutletSnapshot, options: RouterOutletOptions = {}, ): unknown { const pending = selection.pending; @@ -165,126 +128,43 @@ function renderRouterOutlet; - private retryContext: unknown; - private unsubscribe?: () => void; - private boundaryOptions?: RouterOutletBoundaryOptions; - private notFoundScheduled = false; - private pendingMatchId?: string; - private pendingTimer?: ReturnType; - private pendingSelection?: RouterOutletSelection; - private showPending = false; +type RouterOutletInputs = { + router?: Router; + onNotFound?: () => void; +}; - override render( - router: unknown, - retryContext: unknown, - boundaryOptions: RouterOutletBoundaryOptions, +class LitRouterOutletController< + TRouteId extends string, + TLoadContext, + TModule, + TData, +> implements ReactiveController { + private readonly controller: RouterOutletController; + + constructor( + host: ReactiveControllerHost, + private readonly inputs: () => RouterOutletInputs, ) { - const nextRouter = router as Router; - this.updateSubscription(nextRouter); - this.router = nextRouter; - this.retryContext = retryContext; - this.boundaryOptions = boundaryOptions; - return this.renderSelection(selectRouterOutletState(nextRouter.getState())); + this.controller = new RouterOutletController(() => host.requestUpdate()); + host.addController(this); } - override disconnected() { - this.unsubscribe?.(); - this.unsubscribe = undefined; - this.clearPendingTimer(); - this.pendingSelection = undefined; - this.boundaryOptions = undefined; - this.retryContext = undefined; - this.notFoundScheduled = false; + get snapshot(): RouterOutletSnapshot { + return this.controller.snapshot; } - override reconnected() { - if (this.router) { - this.updateSubscription(this.router); - } + hostConnected(): void { + this.controller.setInputs(this.inputs()); + this.controller.connect(); } - private updateSubscription(router: Router) { - if (this.router === router && this.unsubscribe) { - return; - } - this.unsubscribe?.(); - this.unsubscribe = router.subscribeSelector( - selectRouterOutletState, - (selection) => { - if (this.isConnected) { - this.setValue(this.renderSelection(selection)); - } - }, - equalRouterOutletState, - ); + hostUpdate(): void { + this.controller.setInputs(this.inputs()); } - private renderSelection(selection: RouterOutletSelection) { - this.pendingSelection = selection; - const pending = selection.pending; - const coldPending = - pending?.status === "pending" && pending.module === undefined && pending.error === undefined; - const needsPendingFallback = coldPending && !selection.active; - if (!needsPendingFallback) { - this.clearPendingTimer(); - this.pendingMatchId = undefined; - this.showPending = false; - } else if (this.pendingMatchId !== pending.id) { - this.clearPendingTimer(); - this.pendingMatchId = pending.id; - this.showPending = false; - this.pendingTimer = globalThis.setTimeout(() => { - this.pendingTimer = undefined; - const pendingSelection = this.pendingSelection; - if (!pendingSelection || pendingSelection.pending?.id !== this.pendingMatchId) { - return; - } - this.showPending = true; - this.setValue(this.renderSelection(pendingSelection)); - }, PENDING_UI_DELAY_MS); - } - if (selection.status === "notFound") { - if (!this.notFoundScheduled) { - this.notFoundScheduled = true; - queueMicrotask(() => { - this.notFoundScheduled = false; - this.boundaryOptions?.onNotFound?.(); - }); - } - } else { - this.notFoundScheduled = false; - } - const router = this.router; - if (!router) { - return nothing; - } - return renderRouterOutlet( - router, - { ...selection, showPending: this.showPending }, - { - retryContext: this.retryContext, - }, - ); + hostDisconnected(): void { + this.controller.disconnect(); } - - private clearPendingTimer() { - if (this.pendingTimer !== undefined) { - globalThis.clearTimeout(this.pendingTimer); - this.pendingTimer = undefined; - } - } -} - -const routerOutletDirective = directive(RouterOutletDirective); - -function routerOutlet( - router: Router, - boundaryOptions: RouterOutletBoundaryOptions, - options: RouterOutletOptions = {}, -): unknown { - return routerOutletDirective(router, options.retryContext, boundaryOptions); } class OpenClawRouterOutlet< @@ -292,26 +172,22 @@ class OpenClawRouterOutlet< TLoadContext = unknown, TModule = unknown, TData = unknown, -> extends LitElement { +> extends OpenClawLightDomElement { @property({ attribute: false }) router?: Router; @property({ attribute: false }) retryContext?: TLoadContext; @property({ attribute: false }) onNotFound?: () => void; - - override createRenderRoot() { - return this; - } + private readonly outlet = new LitRouterOutletController(this, () => ({ + router: this.router, + onNotFound: this.onNotFound, + })); override render() { if (!this.router) { return nothing; } - return routerOutlet( - this.router, - { onNotFound: this.onNotFound }, - { - retryContext: this.retryContext, - }, - ); + return renderRouterOutlet(this.router, this.outlet.snapshot, { + retryContext: this.retryContext, + }); } } diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts new file mode 100644 index 000000000000..2e8bafdb1971 --- /dev/null +++ b/ui/src/components/app-sidebar.test.ts @@ -0,0 +1,137 @@ +/* @vitest-environment jsdom */ + +import { ContextProvider } from "@lit/context"; +import { LitElement } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { SessionsListResult } from "../api/types.ts"; +import type { RouteId } from "../app-route-paths.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGateway, +} from "../app/context.ts"; +import type { SessionCapability } from "../lib/sessions/index.ts"; +import "./app-sidebar.ts"; + +const PROVIDER_ELEMENT_NAME = "test-app-sidebar-context-provider"; + +class AppSidebarContextProvider extends LitElement { + private readonly contextProvider = new ContextProvider(this, { + context: applicationContext, + }); + + setContext(context: ApplicationContext) { + this.contextProvider.setValue(context); + } +} + +if (!customElements.get(PROVIDER_ELEMENT_NAME)) { + customElements.define(PROVIDER_ELEMENT_NAME, AppSidebarContextProvider); +} + +type SidebarLifecycleState = HTMLElement & { + sessionRowsByAgent: Record; + sessionCreatedOrder: Map; + updateComplete: Promise; +}; + +function createGateway(client: GatewayBrowserClient): ApplicationGateway { + return { + snapshot: { + client, + connected: true, + reconnecting: false, + hello: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + lastError: null, + lastErrorCode: null, + }, + subscribe: () => () => undefined, + } as unknown as ApplicationGateway; +} + +function createSessions(agentId: string, keys: string[]): SessionCapability { + const result = { + ts: 1, + path: "", + count: keys.length, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: keys.map((key, index) => ({ + key, + kind: "direct" as const, + updatedAt: index + 1, + })), + } satisfies SessionsListResult; + return { + state: { + result, + agentId, + modelOverrides: {}, + loading: false, + error: null, + deletedSessions: [], + }, + subscribe: () => () => undefined, + subscribeCreated: () => () => undefined, + } as unknown as SessionCapability; +} + +function createContext( + gateway: ApplicationGateway, + sessions: SessionCapability, +): ApplicationContext { + return { + gateway, + sessions, + agents: { + state: { agentsList: null }, + subscribe: () => () => undefined, + }, + agentSelection: { + state: { selectedId: "main" }, + set: () => undefined, + subscribe: () => () => undefined, + }, + } as unknown as ApplicationContext; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("AppSidebar session source lifecycle", () => { + it("resets cached rows and creation order when the sessions source changes", async () => { + const client = {} as GatewayBrowserClient; + const gateway = createGateway(client); + const provider = document.createElement(PROVIDER_ELEMENT_NAME) as AppSidebarContextProvider; + const sidebar = document.createElement( + "openclaw-app-sidebar", + ) as unknown as SidebarLifecycleState; + provider.setContext(createContext(gateway, createSessions("first", ["first-a", "first-b"]))); + provider.append(sidebar); + document.body.append(provider); + await sidebar.updateComplete; + + expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["first"]); + expect([...sidebar.sessionCreatedOrder]).toEqual([ + ["first-a", 0], + ["first-b", 1], + ]); + + // The Gateway and its client stay unchanged while the sessions capability is replaced. + provider.setContext(createContext(gateway, createSessions("second", ["second-b", "second-a"]))); + await sidebar.updateComplete; + + expect(Object.keys(sidebar.sessionRowsByAgent)).toEqual(["second"]); + expect([...sidebar.sessionCreatedOrder]).toEqual([ + ["second-b", 0], + ["second-a", 1], + ]); + }); +}); diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 19f4aa9b732b..baaf46e9df9b 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -1,5 +1,5 @@ import { consume } from "@lit/context"; -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts"; @@ -56,6 +56,7 @@ import { compareSessionRowsByUpdatedAt, resolveSessionNavigation, searchForSession, + type SessionCapability, } from "../lib/sessions/index.ts"; import { buildAgentMainSessionKey, @@ -69,6 +70,8 @@ import { resolveSessionAgentFilterOptions, } from "../lib/sessions/session-options.ts"; import { normalizeOptionalString } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts"; import { icons, type IconName } from "./icons.ts"; @@ -161,11 +164,7 @@ function shouldHandleNavigationClick(event: MouseEvent): boolean { ); } -class AppSidebar extends LitElement { - override createRenderRoot() { - return this; - } - +class AppSidebar extends OpenClawLightDomElement { @property({ attribute: false }) basePath = ""; @property({ attribute: false }) activeRouteId?: NavigationRouteId; @property({ attribute: false }) activePluginTabId = ""; @@ -187,7 +186,7 @@ class AppSidebar extends LitElement { onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; @property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise; - @consume({ context: applicationContext, subscribe: false }) + @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @state() private customizeMenuPosition: { x: number; y: number } | null = null; @state() private sessionMenu: SidebarSessionMenuState | null = null; @@ -204,27 +203,50 @@ class AppSidebar extends LitElement { @state() private sessionsAgentId: string | null = null; @state() private sessionsLoading = false; - private stopSessionsSubscription: (() => void) | undefined; - private stopSessionCreatedSubscription: (() => void) | undefined; - private stopAgentsSubscription: (() => void) | undefined; - private stopAgentSelectionSubscription: (() => void) | undefined; - private stopGatewaySubscription: (() => void) | undefined; + private readonly subscriptions = new SubscriptionsController(this); private customizeMenuTrigger: HTMLElement | null = null; private sessionMenuTrigger: HTMLElement | null = null; private sessionGroupMenuTrigger: HTMLElement | null = null; private sessionSortMenuTrigger: HTMLElement | null = null; private sessionRowsByAgent: Record = {}; private sessionCreatedOrder = new Map(); + private sessionsSource: SessionCapability | null = null; private gatewayClient: GatewayBrowserClient | null = null; private readonly routePreloadTimers = new Map< EventTarget, ReturnType >(); + constructor() { + super(); + this.subscriptions + .watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.updateGatewayClient(gateway.snapshot), + ) + .watch( + () => this.context?.sessions, + (sessions, notify) => sessions.subscribe(notify), + (sessions) => this.synchronizeSessions(sessions), + ) + .effect( + () => this.context?.sessions, + (sessions) => sessions.subscribeCreated((key) => this.promoteCreatedSession(key)), + ) + .watch( + () => this.context?.agents, + (agents, notify) => agents.subscribe(notify), + ) + .watch( + () => this.context?.agentSelection, + (agentSelection, notify) => agentSelection.subscribe(notify), + ); + } + override connectedCallback() { super.connectedCallback(); this.style.display = "contents"; - this.startSubscriptions(); } override disconnectedCallback() { @@ -232,16 +254,6 @@ class AppSidebar extends LitElement { this.closeSessionMenu(); this.closeSessionGroupMenu(); this.closeSessionSortMenu(); - this.stopSessionsSubscription?.(); - this.stopSessionsSubscription = undefined; - this.stopSessionCreatedSubscription?.(); - this.stopSessionCreatedSubscription = undefined; - this.stopAgentsSubscription?.(); - this.stopAgentsSubscription = undefined; - this.stopAgentSelectionSubscription?.(); - this.stopAgentSelectionSubscription = undefined; - this.stopGatewaySubscription?.(); - this.stopGatewaySubscription = undefined; this.gatewayClient = null; for (const timer of this.routePreloadTimers.values()) { globalThis.clearTimeout(timer); @@ -250,42 +262,6 @@ class AppSidebar extends LitElement { super.disconnectedCallback(); } - private startSubscriptions() { - const context = this.context; - if ( - !context || - this.stopSessionsSubscription || - this.stopSessionCreatedSubscription || - this.stopAgentsSubscription || - this.stopAgentSelectionSubscription || - this.stopGatewaySubscription - ) { - return; - } - this.updateGatewayClient(context.gateway.snapshot); - this.updateSessions(context.sessions.state); - this.stopSessionsSubscription = context.sessions.subscribe((snapshot) => { - this.updateSessions(snapshot); - }); - this.stopSessionCreatedSubscription = context.sessions.subscribeCreated((key) => { - this.promoteCreatedSession(key); - }); - this.stopAgentsSubscription = context.agents.subscribe(() => { - this.requestUpdate(); - }); - this.stopAgentSelectionSubscription = context.agentSelection.subscribe(() => { - this.requestUpdate(); - }); - this.stopGatewaySubscription = context.gateway.subscribe((snapshot) => { - this.updateGatewayClient(snapshot); - this.requestUpdate(); - }); - } - - override updated() { - this.startSubscriptions(); - } - private readonly updateSessions = (snapshot: { result: SessionsListResult | null; agentId: string | null; @@ -306,6 +282,15 @@ class AppSidebar extends LitElement { } }; + private synchronizeSessions(sessions: SessionCapability) { + if (sessions !== this.sessionsSource) { + this.sessionRowsByAgent = {}; + this.sessionCreatedOrder.clear(); + this.sessionsSource = sessions; + } + this.updateSessions(sessions.state); + } + private updateGatewayClient(snapshot: { client: GatewayBrowserClient | null; connected: boolean; diff --git a/ui/src/components/app-topbar.ts b/ui/src/components/app-topbar.ts index 790244061b30..a552de19128c 100644 --- a/ui/src/components/app-topbar.ts +++ b/ui/src/components/app-topbar.ts @@ -1,17 +1,14 @@ -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import type { NavigationRouteId } from "../app-navigation.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { icons } from "./icons.ts"; import "./dashboard-header.ts"; import "./tooltip.ts"; -class AppTopbar extends LitElement { - override createRenderRoot() { - return this; - } - +class AppTopbar extends OpenClawLightDomElement { @property({ attribute: false }) routeId?: NavigationRouteId; @property({ attribute: false }) navDrawerOpen = false; @property({ attribute: false }) onboarding = false; diff --git a/ui/src/components/command-palette.test.ts b/ui/src/components/command-palette.test.ts new file mode 100644 index 000000000000..b56b46e5400d --- /dev/null +++ b/ui/src/components/command-palette.test.ts @@ -0,0 +1,224 @@ +/* @vitest-environment jsdom */ + +import { ContextProvider } from "@lit/context"; +import { LitElement } from "lit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; +import type { SessionsListResult } from "../api/types.ts"; +import type { RouteId } from "../app-route-paths.ts"; +import { + applicationContext, + type ApplicationContext, + type ApplicationGateway, + type ApplicationGatewaySnapshot, +} from "../app/context.ts"; +import { installDialogPolyfill } from "../test-helpers/modal-dialog.ts"; +import { CommandPalette } from "./command-palette.ts"; + +const PROVIDER_ELEMENT_NAME = "test-command-palette-context-provider"; + +class CommandPaletteContextProvider extends LitElement { + private readonly contextProvider = new ContextProvider(this, { + context: applicationContext, + }); + + setContext(context: ApplicationContext) { + this.contextProvider.setValue(context); + } +} + +if (!customElements.get(PROVIDER_ELEMENT_NAME)) { + customElements.define(PROVIDER_ELEMENT_NAME, CommandPaletteContextProvider); +} + +type GatewayHarness = { + gateway: ApplicationGateway; + setConnected: (connected: boolean) => void; +}; + +function createGateway(connected: boolean): GatewayHarness { + const client = {} as GatewayBrowserClient; + let snapshot: ApplicationGatewaySnapshot = { + client, + connected, + reconnecting: !connected, + hello: null, + assistantAgentId: "main", + sessionKey: "main", + lastError: null, + lastErrorCode: null, + }; + const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>(); + const gateway = { + get snapshot() { + return snapshot; + }, + connection: { gatewayUrl: "ws://localhost", token: "", password: "" }, + eventLog: [], + connect: () => undefined, + setSessionKey: () => undefined, + start: () => undefined, + stop: () => undefined, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + subscribeEventLog: () => () => undefined, + subscribeEvents: () => () => undefined, + } satisfies ApplicationGateway; + return { + gateway, + setConnected(nextConnected) { + snapshot = { + ...snapshot, + connected: nextConnected, + reconnecting: !nextConnected, + }; + for (const listener of listeners) { + listener(snapshot); + } + }, + }; +} + +function createContext( + gateway: ApplicationGateway, + list: ApplicationContext["sessions"]["list"], +): ApplicationContext { + return { + gateway, + sessions: { + list, + }, + } as unknown as ApplicationContext; +} + +function createSessionResult(key: string, displayName: string): SessionsListResult { + return { + ts: 1, + path: "", + count: 1, + defaults: {}, + sessions: [{ key, kind: "direct", displayName, updatedAt: 1 }], + } as SessionsListResult; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +async function mountPalette(context: ApplicationContext) { + const provider = document.createElement(PROVIDER_ELEMENT_NAME) as CommandPaletteContextProvider; + const palette = document.createElement("openclaw-command-palette") as CommandPalette; + palette.onNavigate = vi.fn(); + palette.onSelectSession = vi.fn(); + provider.setContext(context); + provider.append(palette); + document.body.append(provider); + await palette.updateComplete; + return { palette, provider }; +} + +async function enterQuery(palette: CommandPalette, query: string) { + palette.openPalette(); + await palette.updateComplete; + const input = palette.querySelector(".cmd-palette__input"); + if (!input) { + throw new Error("Expected command palette input"); + } + input.value = query; + input.dispatchEvent(new Event("input", { bubbles: true, composed: true })); + await palette.updateComplete; +} + +describe("CommandPalette lifecycle", () => { + let restoreDialogPolyfill: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + restoreDialogPolyfill = installDialogPolyfill(); + }); + + afterEach(() => { + document.body.replaceChildren(); + restoreDialogPolyfill(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("closes and clears its query before a retained element reconnects", async () => { + const { gateway } = createGateway(true); + const list = vi.fn(async () => createSessionResult("agent:main:old", "Old chat")); + const { palette, provider } = await mountPalette(createContext(gateway, list)); + await enterQuery(palette, "old"); + await vi.advanceTimersByTimeAsync(250); + await palette.updateComplete; + expect(palette.textContent).toContain("Old chat"); + + palette.remove(); + provider.append(palette); + expect(palette.querySelector("dialog")?.open).toBe(false); + await palette.updateComplete; + + expect(palette.querySelector("dialog")).toBeNull(); + palette.openPalette(); + await palette.updateComplete; + expect(palette.querySelector(".cmd-palette__input")?.value).toBe(""); + expect(palette.textContent).not.toContain("Old chat"); + }); + + it("retries the pending query after the gateway reconnects", async () => { + const harness = createGateway(true); + const stale = createDeferred(); + const list = vi + .fn["sessions"]["list"]>() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce(createSessionResult("agent:main:retry", "Retry chat")); + const { palette } = await mountPalette(createContext(harness.gateway, list)); + await enterQuery(palette, "retry"); + await vi.advanceTimersByTimeAsync(250); + expect(list).toHaveBeenCalledOnce(); + + harness.setConnected(false); + stale.resolve(createSessionResult("agent:main:stale", "Stale chat")); + await Promise.resolve(); + expect(palette.textContent).not.toContain("Stale chat"); + + harness.setConnected(true); + await palette.updateComplete; + await vi.advanceTimersByTimeAsync(250); + await palette.updateComplete; + + expect(list).toHaveBeenCalledTimes(2); + expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ search: "retry" })); + expect(palette.textContent).toContain("Retry chat"); + }); + + it("drops an old provider response and searches the replacement context", async () => { + const initial = createGateway(true); + const replacement = createGateway(true); + const stale = createDeferred(); + const initialList = vi.fn(() => stale.promise); + const replacementList = vi.fn(async () => + createSessionResult("agent:main:fresh", "Fresh chat"), + ); + const { palette, provider } = await mountPalette(createContext(initial.gateway, initialList)); + await enterQuery(palette, "chat"); + await vi.advanceTimersByTimeAsync(250); + expect(initialList).toHaveBeenCalledOnce(); + + stale.resolve(createSessionResult("agent:main:stale", "Stale chat")); + provider.setContext(createContext(replacement.gateway, replacementList)); + await palette.updateComplete; + await vi.advanceTimersByTimeAsync(250); + await palette.updateComplete; + + expect(replacementList).toHaveBeenCalledOnce(); + expect(palette.textContent).toContain("Fresh chat"); + expect(palette.textContent).not.toContain("Stale chat"); + }); +}); diff --git a/ui/src/components/command-palette.ts b/ui/src/components/command-palette.ts index 862c57e59125..1705246d35f1 100644 --- a/ui/src/components/command-palette.ts +++ b/ui/src/components/command-palette.ts @@ -1,6 +1,6 @@ // Control UI component renders the command palette. import { consume } from "@lit/context"; -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { ref } from "lit/directives/ref.js"; import type { RouteId } from "../app-route-paths.ts"; @@ -10,6 +10,8 @@ import { formatRelativeTimestamp } from "../lib/format.ts"; import { resolveSessionDisplayName } from "../lib/session-display.ts"; import { getVisibleSessionRows } from "../lib/sessions/index.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { icons, type IconName } from "./icons.ts"; type PaletteItem = { @@ -104,6 +106,8 @@ type CommandPaletteProps = { onNavigate: (routeId: RouteId) => void; onSelectSession?: (sessionKey: string) => void; onSlashCommand?: (command: string) => void; + onDialogRef: (element: Element | undefined) => void; + onInputRef: (element: Element | undefined) => void; }; function filteredItems( @@ -334,7 +338,7 @@ function renderCommandPalette(props: CommandPaletteProps) { return html` { @@ -356,7 +360,7 @@ function renderCommandPalette(props: CommandPaletteProps) { >${paletteLabel} void; @property({ attribute: false }) onSelectSession?: (sessionKey: string) => void; @property({ attribute: false }) onSlashCommand?: (command: string) => void; - @consume({ context: applicationContext, subscribe: false }) + @consume({ context: applicationContext, subscribe: true }) private context?: ApplicationContext; @state() private open = false; @state() private query = ""; @state() private activeIndex = 0; @state() private sessionItems: readonly PaletteItem[] = []; + private readonly subscriptions = new SubscriptionsController(this); private sessionSearchTimer: ReturnType | null = null; private sessionSearchId = 0; + private sessionSearchSource?: { + gateway: ApplicationContext["gateway"]; + client: ApplicationContext["gateway"]["snapshot"]["client"]; + connected: boolean; + }; + + constructor() { + super(); + this.subscriptions.watch( + () => this.context?.gateway, + (gateway, notify) => gateway.subscribe(notify), + (gateway) => this.synchronizeGateway(gateway), + ); + } override connectedCallback() { super.connectedCallback(); @@ -446,7 +461,11 @@ export class CommandPalette extends LitElement { override disconnectedCallback() { document.removeEventListener("keydown", this.handleGlobalKeydown); + this.open = false; + this.query = ""; + this.activeIndex = 0; this.clearSessionSearch(); + this.sessionSearchSource = undefined; if (activeDialog) { activeDialog.close(); restoreFocus(); @@ -475,6 +494,42 @@ export class CommandPalette extends LitElement { this.openPalette(); }; + private readonly handleDialogRef = (element: Element | undefined) => { + if (!this.open) { + syncDialog(undefined); + return; + } + syncDialog(element); + }; + + private readonly handleInputRef = (element: Element | undefined) => { + if (this.open) { + focusInput(element); + } + }; + + private synchronizeGateway(gateway: ApplicationContext["gateway"]) { + const snapshot = gateway.snapshot; + const previous = this.sessionSearchSource; + const sourceChanged = previous?.gateway !== gateway; + const clientChanged = previous?.client !== snapshot.client; + const reconnected = previous?.connected === false && snapshot.connected; + this.sessionSearchSource = { + gateway, + client: snapshot.client, + connected: snapshot.connected, + }; + + if (sourceChanged || clientChanged || !snapshot.connected) { + // Query results belong to one runtime/client connection. Discard them as + // soon as that owner changes so detached or reconnecting rows stay inert. + this.clearSessionSearch(); + } + if (snapshot.connected && (sourceChanged || clientChanged || reconnected)) { + this.scheduleSessionSearch(this.query); + } + } + private clearSessionSearch() { if (this.sessionSearchTimer !== null) { globalThis.clearTimeout(this.sessionSearchTimer); @@ -494,7 +549,7 @@ export class CommandPalette extends LitElement { this.sessionSearchId += 1; this.sessionItems = []; const search = normalizeOptionalString(query); - if (!search || !this.onSelectSession) { + if (!this.open || !search || !this.onSelectSession) { return; } this.sessionSearchTimer = globalThis.setTimeout(() => { @@ -504,8 +559,11 @@ export class CommandPalette extends LitElement { } private async searchSessions(search: string) { - const sessions = this.context?.sessions; - if (!sessions || !this.context?.gateway.snapshot.connected) { + const context = this.context; + const sessions = context?.sessions; + const gateway = context?.gateway; + const client = gateway?.snapshot.client; + if (!sessions || !gateway?.snapshot.connected || !client) { return; } const requestId = ++this.sessionSearchId; @@ -524,7 +582,15 @@ export class CommandPalette extends LitElement { includeUnknown: false, }); pagesLoaded += 1; - if (requestId !== this.sessionSearchId || !this.open || !result) { + if ( + requestId !== this.sessionSearchId || + !this.open || + this.context?.sessions !== sessions || + this.context?.gateway !== gateway || + gateway.snapshot.client !== client || + !gateway.snapshot.connected || + !result + ) { return; } const pageRows = getVisibleSessionRows(result, { @@ -598,6 +664,8 @@ export class CommandPalette extends LitElement { onNavigate: (routeId) => this.onNavigate?.(routeId), onSelectSession: this.onSelectSession, onSlashCommand: this.onSlashCommand, + onDialogRef: this.handleDialogRef, + onInputRef: this.handleInputRef, }); } } diff --git a/ui/src/components/connection-banner.ts b/ui/src/components/connection-banner.ts index ecc0e4a3b6ef..0f4402b1dd2a 100644 --- a/ui/src/components/connection-banner.ts +++ b/ui/src/components/connection-banner.ts @@ -1,8 +1,9 @@ // Control UI component renders the offline/reconnecting banner shown while // the gateway connection is interrupted but the dashboard stays mounted. -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { redactLoginFailureError } from "./login-gate.ts"; type ConnectionBannerProps = { @@ -28,11 +29,7 @@ function renderConnectionBanner(props: ConnectionBannerProps) { `; } -class ConnectionBanner extends LitElement { - override createRenderRoot() { - return this; - } - +class ConnectionBanner extends OpenClawLightDomElement { @property({ attribute: false }) props?: ConnectionBannerProps; override connectedCallback() { diff --git a/ui/src/components/dashboard-header.ts b/ui/src/components/dashboard-header.ts index 5126f0b59fe5..d07116eaae30 100644 --- a/ui/src/components/dashboard-header.ts +++ b/ui/src/components/dashboard-header.ts @@ -1,13 +1,10 @@ // Control UI component implements the dashboard header element. -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import { titleForRoute, type NavigationRouteId } from "../app-navigation.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; -class DashboardHeader extends LitElement { - override createRenderRoot() { - return this; - } - +class DashboardHeader extends OpenClawLightDomElement { @property() routeId?: NavigationRouteId; @property() basePath = ""; @property() agentLabel = ""; diff --git a/ui/src/components/exec-approval.ts b/ui/src/components/exec-approval.ts index 8c69ded96793..b84e6ee35817 100644 --- a/ui/src/components/exec-approval.ts +++ b/ui/src/components/exec-approval.ts @@ -1,5 +1,5 @@ // Control UI component renders exec approval. -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import { formatApprovalDisplayPath } from "../../../src/infra/approval-display-paths.ts"; import type { @@ -9,6 +9,7 @@ import type { } from "../app/exec-approval.ts"; import "./modal-dialog.ts"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; const DEFAULT_EXEC_APPROVAL_DECISIONS = [ "allow-once", @@ -224,11 +225,7 @@ function renderExecApprovalPrompt(props: ExecApprovalProps) { `; } -class ExecApproval extends LitElement { - override createRenderRoot() { - return this; - } - +class ExecApproval extends OpenClawLightDomElement { @property({ attribute: false }) props?: ExecApprovalProps; override connectedCallback() { diff --git a/ui/src/components/file-preview-modal.test.ts b/ui/src/components/file-preview-modal.test.ts index 761b705ff872..0863c3cba1b4 100644 --- a/ui/src/components/file-preview-modal.test.ts +++ b/ui/src/components/file-preview-modal.test.ts @@ -139,6 +139,26 @@ describe("openclaw-file-preview-modal", () => { expect(onSelect.mock.lastCall?.[0].detail).toBe("filters/auto-senders.txt"); }); + it("restores modal focus when the same element reconnects", async () => { + const modal = await renderPreview(); + const outside = document.createElement("button"); + document.body.append(outside); + + try { + container.remove(); + outside.focus(); + expect(document.activeElement).toBe(outside); + document.body.append(container); + await modal.updateComplete; + + const input = modal.shadowRoot?.querySelector(".search"); + expect(input).toBeInstanceOf(HTMLInputElement); + expect(modal.shadowRoot?.activeElement).toBe(input); + } finally { + outside.remove(); + } + }); + it("blocks background arrow-key scrolling even when no files match", async () => { const modal = await renderPreview({ query: "missing" }); const onDocumentKeydown = vi.fn(); diff --git a/ui/src/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts index a89ca4d46c75..a71d908163d8 100644 --- a/ui/src/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -1,6 +1,7 @@ // Control UI component implements the file preview modal element. -import { LitElement, css, html, type PropertyValues } from "lit"; +import { css, html, type PropertyValues } from "lit"; import { property, query } from "lit/decorators.js"; +import { OpenClawLitElement } from "../lit/openclaw-element.ts"; import { renderCopyButton } from "./copy-button.ts"; import { icons } from "./icons.ts"; @@ -10,7 +11,7 @@ type FilePreviewModalFile = { contents: string; }; -export class OpenClawFilePreviewModal extends LitElement { +export class OpenClawFilePreviewModal extends OpenClawLitElement { @property({ attribute: false }) files: FilePreviewModalFile[] = []; @property() activePath = ""; @property() query = ""; @@ -31,6 +32,8 @@ export class OpenClawFilePreviewModal extends LitElement { private codeSource?: string; private codeChunks: string[] = []; private resetScrollAfterUpdate = true; + // Reconnection does not rerun firstUpdated; defer focus until shadow DOM is ready. + private focusAfterUpdate = false; static override styles = css` :host { @@ -607,13 +610,10 @@ export class OpenClawFilePreviewModal extends LitElement { return files.find((file) => file.path === this.activePath) ?? files[0]; } - protected override firstUpdated() { - this.focusModal(); - } - override connectedCallback() { super.connectedCallback(); this.resetScrollAfterUpdate = true; + this.focusAfterUpdate = true; this.requestUpdate(); } @@ -629,6 +629,10 @@ export class OpenClawFilePreviewModal extends LitElement { if (changed.has("activePath") || changed.has("query") || changed.has("files")) { this.scrollActiveFileIntoView(); } + if (this.focusAfterUpdate && this.isConnected) { + this.focusAfterUpdate = false; + this.focusModal(); + } } private handleQueryInput = (event: Event) => { diff --git a/ui/src/components/gateway-url-confirmation.ts b/ui/src/components/gateway-url-confirmation.ts index ab4d13fe11fe..f406471da723 100644 --- a/ui/src/components/gateway-url-confirmation.ts +++ b/ui/src/components/gateway-url-confirmation.ts @@ -1,7 +1,8 @@ // Control UI component renders gateway URL confirmation. -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import "./modal-dialog.ts"; type GatewayUrlConfirmationProps = { @@ -45,11 +46,7 @@ function renderGatewayUrlConfirmation(props: GatewayUrlConfirmationProps) { `; } -class GatewayUrlConfirmation extends LitElement { - override createRenderRoot() { - return this; - } - +class GatewayUrlConfirmation extends OpenClawLightDomElement { @property({ attribute: false }) props?: GatewayUrlConfirmationProps; override connectedCallback() { diff --git a/ui/src/components/login-gate.ts b/ui/src/components/login-gate.ts index c9261aba51ad..13bacd5f0233 100644 --- a/ui/src/components/login-gate.ts +++ b/ui/src/components/login-gate.ts @@ -1,5 +1,5 @@ // Control UI component renders the login gate. -import { LitElement, html, nothing } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; import { normalizeBasePath } from "../app-route-paths.ts"; @@ -12,6 +12,7 @@ import { shouldShowInsecureContextHint, } from "../lib/overview-hints.ts"; import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { renderConnectCommand } from "./connect-command.ts"; import { icons } from "./icons.ts"; @@ -431,11 +432,7 @@ function renderLoginGate(props: LoginGateProps) { `; } -class LoginGate extends LitElement { - override createRenderRoot() { - return this; - } - +class LoginGate extends OpenClawLightDomElement { @property({ attribute: false }) props?: LoginGateProps; override connectedCallback() { diff --git a/ui/src/components/modal-dialog.test.ts b/ui/src/components/modal-dialog.test.ts index 386c1f57fa7f..532996db7e77 100644 --- a/ui/src/components/modal-dialog.test.ts +++ b/ui/src/components/modal-dialog.test.ts @@ -149,4 +149,18 @@ describe("openclaw-modal-dialog", () => { expect(document.activeElement).toBe(returnTarget); returnTarget.remove(); }); + + it("reopens the same dialog element after reconnect", async () => { + const { modal, dialog } = await renderModal(); + + modal.remove(); + expect(dialog.open).toBe(false); + + container.append(modal); + await modal.updateComplete; + await nextFrame(); + + expect(dialog.open).toBe(true); + expect(modal.shadowRoot?.activeElement).toBe(dialog); + }); }); diff --git a/ui/src/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts index f84dfa6e90b2..ab9ee631bd70 100644 --- a/ui/src/components/modal-dialog.ts +++ b/ui/src/components/modal-dialog.ts @@ -1,7 +1,8 @@ // Control UI component implements the modal dialog element. -import { LitElement, css, html, nothing } from "lit"; +import { css, html, nothing } from "lit"; import { property, query } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { OpenClawLitElement } from "../lit/openclaw-element.ts"; const FOCUSABLE_SELECTOR = [ "a[href]", @@ -13,7 +14,7 @@ const FOCUSABLE_SELECTOR = [ "[tabindex]:not([tabindex='-1'])", ].join(","); -export class OpenClawModalDialog extends LitElement { +export class OpenClawModalDialog extends OpenClawLitElement { @property() label = ""; @property() description = ""; @@ -85,6 +86,10 @@ export class OpenClawModalDialog extends LitElement { override connectedCallback() { super.connectedCallback(); this.previouslyFocused = this.ownerDocument.activeElement; + // firstUpdated only runs once; retained dialogs must reopen on later connection epochs. + if (this.hasUpdated) { + this.openDialog(); + } } override firstUpdated() { @@ -152,6 +157,7 @@ export class OpenClawModalDialog extends LitElement { } private closeDialog() { + this.opened = false; const dialog = this.dialogElement; if (!dialog?.open) { return; diff --git a/ui/src/components/resizable-divider.ts b/ui/src/components/resizable-divider.ts index e2f4461c375b..80088bd2a1ed 100644 --- a/ui/src/components/resizable-divider.ts +++ b/ui/src/components/resizable-divider.ts @@ -1,12 +1,13 @@ // Control UI component implements the resizable divider element. -import { LitElement, css, nothing } from "lit"; +import { css, nothing } from "lit"; import { property } from "lit/decorators.js"; +import { OpenClawLitElement } from "../lit/openclaw-element.ts"; /** * An accessible draggable divider for resizable split views. * Dispatches 'resize' events with { splitRatio: number } detail. */ -export class ResizableDivider extends LitElement { +export class ResizableDivider extends OpenClawLitElement { @property({ type: Number }) splitRatio = 0.6; @property({ type: Number }) minRatio = 0.4; @property({ type: Number }) maxRatio = 0.7; diff --git a/ui/src/components/session-menu.ts b/ui/src/components/session-menu.ts index 4be037f7b405..3ae937b18eac 100644 --- a/ui/src/components/session-menu.ts +++ b/ui/src/components/session-menu.ts @@ -1,6 +1,7 @@ -import { LitElement, html, nothing, type PropertyValues } from "lit"; +import { html, nothing, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { icons } from "./icons.ts"; export type SessionMenuData = { @@ -33,11 +34,7 @@ const EMPTY_SESSION: SessionMenuData = { category: null, }; -class SessionMenu extends LitElement { - override createRenderRoot() { - return this; - } - +class SessionMenu extends OpenClawLightDomElement { @property({ attribute: false }) session: SessionMenuData = EMPTY_SESSION; @property({ attribute: false }) x = 0; @property({ attribute: false }) y = 0; diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index 33d43e261b9d..372f7c3b14e0 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -19,10 +19,9 @@ type SettingsSidebarProps = { onExit: () => void; onNavigate: (routeId: RouteId) => void; onPreload?: (routeId: RouteId) => Promise | void; + preloadTimers: Map>; }; -const preloadTimers = new Map>(); - function renderItem(props: SettingsSidebarProps, routeId: RouteId) { const active = props.activeRouteId === routeId; return html` @@ -31,13 +30,13 @@ function renderItem(props: SettingsSidebarProps, routeId: RouteId) { class="settings-sidebar__item ${active ? "settings-sidebar__item--active" : ""}" aria-current=${active ? "page" : nothing} @focus=${(event: Event) => - scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active)} - @blur=${(event: Event) => cancelRoutePreload(preloadTimers, event)} + scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active)} + @blur=${(event: Event) => cancelRoutePreload(props.preloadTimers, event)} @pointerenter=${(event: Event) => - scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active)} - @pointerleave=${(event: Event) => cancelRoutePreload(preloadTimers, event)} + scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active)} + @pointerleave=${(event: Event) => cancelRoutePreload(props.preloadTimers, event)} @touchstart=${(event: TouchEvent) => - scheduleRoutePreload(preloadTimers, routeId, event, props.onPreload, active, true)} + scheduleRoutePreload(props.preloadTimers, routeId, event, props.onPreload, active, true)} @click=${(event: MouseEvent) => { if ( event.defaultPrevented || diff --git a/ui/src/components/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts index 0d85c7edd712..6f643ff7bd45 100644 --- a/ui/src/components/terminal/terminal-panel.test.ts +++ b/ui/src/components/terminal/terminal-panel.test.ts @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../../i18n/index.ts"; import type { TerminalGatewayClient } from "./terminal-connection.ts"; type CreateOptions = { @@ -12,6 +13,39 @@ type CreateOptions = { const createGhosttyTerminalMock = vi.hoisted(() => vi.fn()); +function createTerminalController(dispose: () => void = vi.fn()) { + return { + terminal: { + cols: 100, + rows: 30, + viewportY: 0, + write: vi.fn(), + focus: vi.fn(), + }, + write: vi.fn(), + fit: vi.fn(), + dispose, + }; +} + +function terminalOpenResult(sessionId: string) { + return { + sessionId, + agentId: "ops", + shell: "/bin/zsh", + cwd: "/work/ops", + confined: false, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + vi.mock("./terminal-runtime.ts", () => { return { createIsolatedGhosttyTerminal: createGhosttyTerminalMock }; }); @@ -19,11 +53,16 @@ vi.mock("./terminal-runtime.ts", () => { import { OpenClawTerminalPanel } from "./terminal-panel.ts"; describe("OpenClawTerminalPanel", () => { - afterEach(() => { + beforeEach(async () => { + await i18n.setLocale("en"); + }); + + afterEach(async () => { document.body.replaceChildren(); localStorage.clear(); sessionStorage.clear(); createGhosttyTerminalMock.mockReset(); + await i18n.setLocale("en"); }); it("opens new sessions for the selected agent", async () => { @@ -230,4 +269,174 @@ describe("OpenClawTerminalPanel", () => { expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2); expect(controllers[1].write).not.toHaveBeenCalled(); }); + + it("rebinds to a replacement client while availability stays true", async () => { + const controllers = [createTerminalController(), createTerminalController()]; + createGhosttyTerminalMock + .mockResolvedValueOnce(controllers[0]) + .mockResolvedValueOnce(controllers[1]); + + const oldRequests: string[] = []; + const oldUnsubscribe = vi.fn(); + const oldClient: TerminalGatewayClient = { + request: async (method: string) => { + oldRequests.push(method); + return (method === "terminal.open" ? terminalOpenResult("old-session") : {}) as T; + }, + addEventListener: () => oldUnsubscribe, + }; + const newRequests: string[] = []; + const newClient: TerminalGatewayClient = { + request: async (method: string) => { + newRequests.push(method); + if (method === "terminal.list") { + return { sessions: [] } as T; + } + return (method === "terminal.open" ? terminalOpenResult("new-session") : {}) as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + panel.client = oldClient; + panel.available = true; + document.body.append(panel); + panel.toggle(); + + await vi.waitFor(() => { + expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toContain("old-session"); + }); + panel.client = newClient; + await panel.updateComplete; + + await vi.waitFor(() => { + expect(newRequests).toContain("terminal.open"); + }); + expect(oldRequests.filter((method) => method === "terminal.open")).toHaveLength(1); + expect(oldUnsubscribe).toHaveBeenCalledOnce(); + expect(controllers[0].dispose).toHaveBeenCalledOnce(); + expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2); + }); + + it("discards an async boot that finishes after disconnect and reconnect", async () => { + const staleController = createTerminalController(); + const currentController = createTerminalController(); + const staleBoot = deferred(); + createGhosttyTerminalMock + .mockImplementationOnce(async () => staleBoot.promise) + .mockResolvedValueOnce(currentController); + const requests: string[] = []; + const client: TerminalGatewayClient = { + request: async (method: string) => { + requests.push(method); + return (method === "terminal.open" ? terminalOpenResult("current-session") : {}) as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + + await vi.waitFor(() => { + expect(createGhosttyTerminalMock).toHaveBeenCalledOnce(); + }); + const staleOptions = createGhosttyTerminalMock.mock.calls[0]![0] as CreateOptions; + const staleHost = staleOptions.parent; + panel.remove(); + document.body.append(panel); + + await vi.waitFor(() => { + expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2); + expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1); + }); + staleBoot.resolve(staleController); + + await vi.waitFor(() => { + expect(staleController.dispose).toHaveBeenCalledOnce(); + }); + expect(staleHost.isConnected).toBe(false); + expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1); + expect(currentController.dispose).not.toHaveBeenCalled(); + }); + + it("removes resize listeners when disconnected mid-drag", async () => { + createGhosttyTerminalMock.mockResolvedValue(createTerminalController()); + const client: TerminalGatewayClient = { + request: async (method: string) => + (method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T, + addEventListener: () => () => {}, + }; + const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + await panel.updateComplete; + + panel.renderRoot + .querySelector(".tp-resizer") + ?.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 20, clientY: 200 })); + panel.remove(); + window.dispatchEvent(new MouseEvent("pointermove", { clientX: 20, clientY: 20 })); + + expect(document.documentElement.style.getPropertyValue("--oc-terminal-reserve-bottom")).toBe( + "0px", + ); + expect(document.documentElement.style.getPropertyValue("--oc-terminal-reserve-right")).toBe( + "0px", + ); + }); + + it("removes a tab host even when controller disposal throws", () => { + const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const host = document.createElement("div"); + document.body.append(host); + const dispose = vi.fn(() => { + throw new Error("dispose failed"); + }); + const disposeTab = ( + panel as unknown as { + disposeTab(tab: { controller: { dispose(): void }; host: HTMLDivElement }): void; + } + ).disposeTab.bind(panel); + + expect(() => disposeTab({ controller: { dispose }, host })).not.toThrow(); + expect(dispose).toHaveBeenCalledOnce(); + expect(host.isConnected).toBe(false); + }); + + it("retranslates cached exit state when the locale changes", async () => { + createGhosttyTerminalMock.mockResolvedValue(createTerminalController()); + let listener: ((event: { event: string; payload: unknown }) => void) | undefined; + const client: TerminalGatewayClient = { + request: async (method: string) => + (method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T, + addEventListener: (nextListener) => { + listener = nextListener; + return () => { + listener = undefined; + }; + }, + }; + const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + await vi.waitFor(() => { + expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toContain("session-1"); + }); + + listener?.({ + event: "terminal.exit", + payload: { sessionId: "session-1", exitCode: null, reason: "detached" }, + }); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-tab__status")?.textContent).toBe("detached"); + + await i18n.setLocale("de"); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-tab__status")?.textContent).toBe("getrennt"); + }); }); diff --git a/ui/src/components/terminal/terminal-panel.ts b/ui/src/components/terminal/terminal-panel.ts index 7a6eaeea70ec..f7c3108a189e 100644 --- a/ui/src/components/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -5,9 +5,10 @@ import type { GhosttyTerminalController } from "@openclaw/libterminal/browser"; // 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 { LitElement, css, html, nothing, svg } from "lit"; +import { css, html, nothing, svg } from "lit"; import { property, state } from "lit/decorators.js"; import { t } from "../../i18n/index.ts"; +import { OpenClawLitElement } from "../../lit/openclaw-element.ts"; import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts"; import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts"; import { terminalTheme } from "./terminal-theme.ts"; @@ -34,22 +35,25 @@ type PanelLayout = { type TerminalTabState = { id: string; + sequence: number; gatewaySessionId: string; /** Shell basename shown on the tab, e.g. "zsh". */ - shellName: string; - /** Agent + cwd shown on hover. */ - hint: string; + shellName: string | null; + agentId: string | null; + cwd: string | null; controller: GhosttyTerminalController; host: HTMLDivElement; status: "live" | "exited"; - statusLabel?: string; - /** - * Set when the tab is closed while its terminal.open RPC is still in flight - * (gatewaySessionId is empty in that window, so closeTab cannot close the - * server session). The open continuation checks this and closes the freshly - * created session instead of wiring it to the disposed terminal. - */ - cancelled?: boolean; + exitReason?: string; + exitCode?: number | null; + /** Why an in-flight open/attach must not adopt this disposed terminal. */ + cancelled?: "close" | "lifecycle"; +}; + +type TerminalOperation = { + generation: number; + client: TerminalGatewayClient; + signal: AbortSignal; }; /** Reduces a shell path to a tab label, e.g. "/bin/zsh" -> "zsh". */ @@ -58,6 +62,29 @@ function shellBasename(shell: string): string { return base && base.length > 0 ? base : "shell"; } +function terminalTabLabel(tab: TerminalTabState): string { + return tab.shellName ?? t("terminal.tabLabel", { n: String(tab.sequence) }); +} + +function terminalTabHint(tab: TerminalTabState): string | null { + if (tab.agentId === null || tab.cwd === null) { + return null; + } + return t("terminal.tabHint", { agent: tab.agentId, cwd: tab.cwd }); +} + +function terminalTabStatusLabel(tab: TerminalTabState): string | null { + if (tab.status !== "exited") { + return null; + } + if (tab.exitReason === "detached") { + return t("terminal.detached"); + } + return tab.exitReason === "process_exit" && typeof tab.exitCode === "number" + ? t("terminal.exitedCode", { code: String(tab.exitCode) }) + : t("terminal.exited"); +} + const LAYOUT_KEY = "openclaw.terminal.panel.v1"; // Session ids for reattach after a reload/reconnect. Deliberately // sessionStorage, not localStorage: attach is take-over, and a shared @@ -125,7 +152,7 @@ function loadPersistedSessionIds(): string[] { } /** `` — the dockable Control UI shell surface. */ -export class OpenClawTerminalPanel extends LitElement { +export class OpenClawTerminalPanel extends OpenClawLitElement { /** Gateway client used for terminal.* RPCs; null until connected. */ @property({ attribute: false }) client: TerminalGatewayClient | null = null; /** Agent whose workspace and sandbox policy own newly opened sessions. */ @@ -150,6 +177,12 @@ export class OpenClawTerminalPanel extends LitElement { @state() private errorText: string | null = null; private connection: TerminalConnection | null = null; + private activeClient: TerminalGatewayClient | null = null; + private activeAvailable = false; + private lifecycleGeneration = 0; + private lifecycleAbortController = new AbortController(); + private lifecycleSyncToken = 0; + private resizeCleanup: (() => void) | null = null; private tabSeq = 0; private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); @@ -169,6 +202,8 @@ export class OpenClawTerminalPanel extends LitElement { override connectedCallback(): void { super.connectedCallback(); + this.activeClient = this.client; + this.activeAvailable = this.available; if (!this.fullscreen) { const layout = loadLayout(); this.dock = layout.dock; @@ -198,27 +233,13 @@ export class OpenClawTerminalPanel extends LitElement { document.documentElement.style.setProperty("--oc-terminal-reserve-bottom", "0px"); document.documentElement.style.setProperty("--oc-terminal-reserve-right", "0px"); this.disposeAllTabs(); + this.activeClient = null; + this.activeAvailable = false; } override updated(changed: Map): void { - if (changed.has("available")) { - if (!this.available) { - // The surface disappeared (gateway disconnect/disable). Tear down local - // tabs and the connection (disposeAllTabs drops the gateway - // subscription too). Server sessions survive a disconnect for the - // detach grace period, and their ids stay persisted, so the restore on - // reconnect reattaches them instead of opening fresh shells. Hide the - // panel WITHOUT persisting: a disconnect must not overwrite the user's - // open preference, or the reconnect path would never auto-reopen. - this.open = false; - this.disposeAllTabs(); - } else if (!this.open && (this.fullscreen || loadLayout().open)) { - // Hello arrived after mount (or a reconnect); restore the persisted - // open state (fullscreen documents are always open while available) - // and reattach persisted sessions where possible. - this.open = true; - void this.restoreSessions(); - } + if (changed.has("client") || changed.has("available")) { + this.scheduleLifecycleSync(); } if (changed.has("themeMode")) { const theme = terminalTheme(this.themeMode); @@ -251,6 +272,61 @@ export class OpenClawTerminalPanel extends LitElement { this.syncLayoutReservation(); } + private scheduleLifecycleSync(): void { + const token = ++this.lifecycleSyncToken; + const generation = this.lifecycleGeneration; + // State teardown inside Lit's updated hook schedules a nested update. + // Defer it; token + generation reject superseded connection epochs. + queueMicrotask(() => { + if ( + token !== this.lifecycleSyncToken || + generation !== this.lifecycleGeneration || + !this.isConnected + ) { + return; + } + this.synchronizeLifecycle(); + }); + } + + private synchronizeLifecycle(): void { + const clientChanged = this.client !== this.activeClient; + const availabilityChanged = this.available !== this.activeAvailable; + if (!clientChanged && !availabilityChanged) { + return; + } + if (clientChanged) { + this.activeClient = this.client; + } + this.activeAvailable = this.available; + const becameUnavailable = availabilityChanged && !this.available; + if (clientChanged || becameUnavailable) { + this.disposeAllTabs(); + } + let shouldRestore = clientChanged && this.available && this.open; + if (availabilityChanged) { + if (!this.available) { + // The surface disappeared (gateway disconnect/disable). Tear down local + // tabs and the connection (disposeAllTabs drops the gateway + // subscription too). Server sessions survive a disconnect for the + // detach grace period, and their ids stay persisted, so the restore on + // reconnect reattaches them instead of opening fresh shells. Hide the + // panel WITHOUT persisting: a disconnect must not overwrite the user's + // open preference, or the reconnect path would never auto-reopen. + this.open = false; + } else if (!this.open && (this.fullscreen || loadLayout().open)) { + // Hello arrived after mount (or a reconnect); restore the persisted + // open state (fullscreen documents are always open while available) + // and reattach persisted sessions where possible. + this.open = true; + shouldRestore = true; + } + } + if (shouldRestore) { + void this.restoreSessions(); + } + } + /** * Publishes the dock's footprint as CSS variables on the document root so the * Control UI shell reserves space for it (via `.content` margins) instead of @@ -326,27 +402,39 @@ export class OpenClawTerminalPanel extends LitElement { * the gateway still has them, otherwise fall back to one fresh session. */ private async restoreSessions(): Promise { - if (!this.client || !this.available || this.booting || this.tabs.length > 0) { - await this.ensureInitialSession(); + const operation = this.captureTerminalOperation(); + if (!operation || this.booting || this.tabs.length > 0) { return; } const persisted = loadPersistedSessionIds(); if (persisted.length > 0) { this.booting = true; try { - if (!this.connection) { - this.connection = new TerminalConnection(this.client); + const connection = this.connectionFor(operation); + const listed = await connection.list(); + if (!this.isTerminalOperationCurrent(operation)) { + return; } - const listed = await this.connection.list(); const known = new Set(listed.map((session) => session.sessionId)); for (const sessionId of persisted.filter((id) => known.has(id))) { - await this.attachSession(sessionId); + await this.attachSession(sessionId, operation); + if (!this.isTerminalOperationCurrent(operation)) { + return; + } } } catch { + if (!this.isTerminalOperationCurrent(operation)) { + return; + } // terminal.list failed (older gateway, surface flapping): fall through // to a fresh session below. } finally { - this.booting = false; + if (this.isTerminalOperationCurrent(operation)) { + this.booting = false; + } + } + if (!this.isTerminalOperationCurrent(operation)) { + return; } // Prune ids the gateway no longer knows (reaped or externally closed). this.persistLiveSessions(); @@ -361,27 +449,24 @@ export class OpenClawTerminalPanel extends LitElement { } /** Boots a tab with a libterminal controller, ready for an open or attach RPC. */ - private async bootTab(): Promise<{ + private async bootTab(operation: TerminalOperation): Promise<{ tab: TerminalTabState; connection: TerminalConnection; cols: number; rows: number; }> { - if (!this.client) { - throw new Error("terminal client unavailable"); - } - if (!this.connection) { - this.connection = new TerminalConnection(this.client); - } + const connection = this.connectionFor(operation); // Captured so the cancelled-open cleanup can close the session even if a // teardown swaps this.connection while the open/attach RPC is in flight. - const connection = this.connection; const host = document.createElement("div"); host.className = "tp-host"; const id = `tab-${++this.tabSeq}`; // Wait for the panel (and its .tp-viewport) to render before attaching the // ghostty host, so the terminal opens into a laid-out, measurable node. await this.updateComplete; + if (!this.isTerminalOperationCurrent(operation)) { + throw new Error("terminal operation cancelled"); + } const viewport = this.renderRoot.querySelector(".tp-viewport"); if (!viewport) { throw new Error("terminal viewport unavailable"); @@ -400,6 +485,7 @@ export class OpenClawTerminalPanel extends LitElement { theme: terminalTheme(this.themeMode), scrollback: 5000, }, + signal: operation.signal, // The browser controller owns these subscriptions and their teardown. // Ignore startup callbacks until the Gateway session is adopted. onData: (bytes) => { @@ -419,11 +505,21 @@ export class OpenClawTerminalPanel extends LitElement { host.remove(); throw error; } + if (!this.isTerminalOperationCurrent(operation)) { + try { + controller.dispose(); + } finally { + host.remove(); + } + throw new Error("terminal operation cancelled"); + } const tab: TerminalTabState = { id, + sequence: this.tabSeq, gatewaySessionId: "", - shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }), - hint: "", + shellName: null, + agentId: null, + cwd: null, controller, host, status: "live", @@ -456,7 +552,8 @@ export class OpenClawTerminalPanel extends LitElement { ): void { tab.gatewaySessionId = result.sessionId; tab.shellName = shellBasename(result.shell); - tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd }); + tab.agentId = result.agentId; + tab.cwd = result.cwd; // Libterminal observes layout before the Gateway session exists. Resync the // current grid now so a resize during the open/attach RPC is not lost. const { cols, rows } = tab.controller.terminal; @@ -476,7 +573,8 @@ export class OpenClawTerminalPanel extends LitElement { } private async openSession(): Promise { - if (!this.client || !this.available || this.booting) { + const operation = this.captureTerminalOperation(); + if (!operation || this.booting) { return; } this.booting = true; @@ -486,43 +584,60 @@ export class OpenClawTerminalPanel extends LitElement { // Tracked outside the try so the catch can dispose a tab whose open failed. let createdTab: TerminalTabState | undefined; try { - const boot = await this.bootTab(); + const boot = await this.bootTab(operation); createdTab = boot.tab; const result = await boot.connection.open( { agentId, cols: boot.cols, rows: boot.rows }, this.tabSink(boot.tab), ); - if (boot.tab.cancelled) { + if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) { // The tab's close button was clicked while the open RPC was in flight. // The server session is live and its sink registered; close it now or // it survives invisibly (eating the session cap) until disconnect. void boot.connection.close(result.sessionId); + if (this.tabs.includes(boot.tab)) { + boot.tab.cancelled = "lifecycle"; + this.dropFailedTab(boot.tab); + } return; } this.adoptSession(boot.tab, result); boot.tab.controller.terminal.focus(); } catch (err) { - this.errorText = err instanceof Error ? err.message : String(err); // A failed open (e.g. terminal disabled or a sandboxed agent is refused) // must not leave a phantom "live" tab with no server session. Drop it but // keep the panel open so the error stays visible. - if (createdTab && !createdTab.gatewaySessionId) { + if (createdTab && !createdTab.gatewaySessionId && this.tabs.includes(createdTab)) { this.dropFailedTab(createdTab); } + if (!this.isTerminalOperationCurrent(operation)) { + return; + } + this.errorText = err instanceof Error ? err.message : String(err); } finally { - this.booting = false; + if (this.isTerminalOperationCurrent(operation)) { + this.booting = false; + } } } /** Reattaches one persisted session; returns false when it is gone. */ - private async attachSession(sessionId: string): Promise { + private async attachSession(sessionId: string, operation: TerminalOperation): Promise { let createdTab: TerminalTabState | undefined; try { - const boot = await this.bootTab(); + const boot = await this.bootTab(operation); createdTab = boot.tab; const result = await boot.connection.attach(sessionId, this.tabSink(boot.tab)); - if (boot.tab.cancelled) { - void boot.connection.close(result.sessionId); + if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) { + // A user close is deliberate; lifecycle cancellation leaves the existing + // server session available for the next reconnect to reattach. + if (boot.tab.cancelled === "close") { + void boot.connection.close(result.sessionId); + } + if (this.tabs.includes(boot.tab)) { + boot.tab.cancelled = "lifecycle"; + this.dropFailedTab(boot.tab); + } return false; } this.adoptSession(boot.tab, result); @@ -531,7 +646,7 @@ export class OpenClawTerminalPanel extends LitElement { // Session expired between list and attach (reaper race) or an older // gateway: quietly drop the placeholder tab; restore falls back to a // fresh session when nothing could be reattached. - if (createdTab && !createdTab.gatewaySessionId) { + if (createdTab && !createdTab.gatewaySessionId && this.tabs.includes(createdTab)) { this.dropFailedTab(createdTab); } return false; @@ -544,15 +659,8 @@ export class OpenClawTerminalPanel extends LitElement { return; } tab.status = "exited"; - if (info.reason === "detached") { - // Another connection attached this session away; it is alive elsewhere. - tab.statusLabel = t("terminal.detached"); - } else { - tab.statusLabel = - info.reason === "process_exit" && info.exitCode !== null - ? t("terminal.exitedCode", { code: String(info.exitCode) }) - : t("terminal.exited"); - } + tab.exitReason = info.reason; + tab.exitCode = info.exitCode; // The connection drops its own sink on exit delivery, so no release() here — // the session id may not be recorded yet when an early exit is replayed. this.tabs = [...this.tabs]; @@ -569,7 +677,7 @@ export class OpenClawTerminalPanel extends LitElement { } else if (!tab.gatewaySessionId && tab.status === "live") { // Open still in flight: no session id to close yet. Flag it so the open // continuation closes the server session as soon as the RPC resolves. - tab.cancelled = true; + tab.cancelled = "close"; } this.disposeTab(tab); this.tabs = this.tabs.filter((entry) => entry.id !== tabId); @@ -595,16 +703,55 @@ export class OpenClawTerminalPanel extends LitElement { }); } + private captureTerminalOperation(): TerminalOperation | null { + const client = this.client; + if (!client || client !== this.activeClient || !this.available || !this.isConnected) { + return null; + } + return { + generation: this.lifecycleGeneration, + client, + signal: this.lifecycleAbortController.signal, + }; + } + + private isTerminalOperationCurrent(operation: TerminalOperation): boolean { + return ( + this.isConnected && + this.available && + this.client === operation.client && + this.activeClient === operation.client && + this.lifecycleGeneration === operation.generation && + !operation.signal.aborted + ); + } + + private connectionFor(operation: TerminalOperation): TerminalConnection { + if (!this.isTerminalOperationCurrent(operation)) { + throw new Error("terminal operation cancelled"); + } + this.connection ??= new TerminalConnection(operation.client); + return this.connection; + } + private disposeTab(tab: TerminalTabState): void { try { tab.controller.dispose(); - tab.host.remove(); } catch { // Best-effort teardown; a partially-initialized tab may throw. + } finally { + // DOM ownership is independent of controller cleanup; never strand a + // Ghostty canvas when dependency disposal fails partway through. + tab.host.remove(); } } private disposeAllTabs(): void { + this.lifecycleGeneration += 1; + this.lifecycleAbortController.abort(); + this.lifecycleAbortController = new AbortController(); + this.booting = false; + this.clearResizeListeners(); for (const tab of this.tabs) { // No terminal.close here: this teardown runs for disconnects, // availability loss, and element removal — exactly the sessions the @@ -614,7 +761,7 @@ export class OpenClawTerminalPanel extends LitElement { // The cancelled flag covers a tab whose open RPC is still in flight; its // continuation closes the fresh session instead of adopting the // disposed terminal. - tab.cancelled = true; + tab.cancelled = "lifecycle"; this.disposeTab(tab); } this.tabs = []; @@ -668,6 +815,7 @@ export class OpenClawTerminalPanel extends LitElement { private startResize(event: PointerEvent): void { event.preventDefault(); + this.clearResizeListeners(); const startX = event.clientX; const startY = event.clientY; const startHeight = this.height; @@ -685,13 +833,32 @@ export class OpenClawTerminalPanel extends LitElement { const active = this.tabs.find((tab) => tab.id === this.activeId); active?.controller.fit(); }; - const onUp = () => { + const cleanup = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + window.removeEventListener("blur", onUp); + if (this.resizeCleanup === cleanup) { + this.resizeCleanup = null; + } + }; + const onUp = () => { + cleanup(); + if (!this.isConnected) { + return; + } this.persistLayout(); }; + this.resizeCleanup = cleanup; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + window.addEventListener("blur", onUp); + } + + private clearResizeListeners(): void { + this.resizeCleanup?.(); + this.resizeCleanup = null; } override render() { @@ -716,22 +883,23 @@ export class OpenClawTerminalPanel extends LitElement { >`}
- ${this.tabs.map( - (tab) => html` + ${this.tabs.map((tab) => { + const statusLabel = terminalTabStatusLabel(tab); + return html` - `, - )} + `; + })}