diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f85c086e75..c99716d6e1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI native session catalogs:** omit the Gateway's same-install node-host copies from Codex and Claude Code catalogs, hide empty host groups, and remove the empty Coding section when no live work sessions exist. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. - **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended. diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index a21f93dfe05b..703def64d7b3 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -2568,6 +2568,51 @@ describe("Claude session catalog", () => { ]); }); + it("omits the Gateway's same-install node host from native discovery", async () => { + const home = await createHome(); + process.env.HOME = home; + const invoke = vi.fn(async ({ nodeId }: { nodeId: string }) => ({ + payloadJSON: JSON.stringify({ + sessions: [ + { + threadId: `remote-${nodeId}`, + status: "stored", + source: "claude-cli", + archived: false, + }, + ], + }), + })); + const provider = captureCatalogProvider({ + nodes: { + list: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId: "gateway-node", + displayName: "Gateway node", + gatewayLocal: true, + connected: true, + commands: [CLAUDE_SESSIONS_LIST_COMMAND], + }, + { + nodeId: "remote-node", + displayName: "Remote node", + connected: true, + commands: [CLAUDE_SESSIONS_LIST_COMMAND], + }, + ], + }), + invoke, + }, + } as unknown as PluginRuntime); + + const hosts = await provider.list({}); + + expect(hosts.map((host) => host.hostId)).toEqual(["gateway:local", "node:remote-node"]); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" })); + }); + it("bounds how long a hung paired-node catalog can delay the caller", async () => { vi.useFakeTimers(); try { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index ceddf60995c7..a2af923bdfd6 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1497,6 +1497,7 @@ async function listClaudeSessionCatalog(params: { const eligible = nodes .filter( (node) => + node.gatewayLocal !== true && node.commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) && (!requested || requested.has(`node:${node.nodeId}`)), ) diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 7fbe4b406117..fe7502eacc94 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -1162,6 +1162,51 @@ describe("Codex supervision catalog", () => { }); }); + it("omits the Gateway's same-install node host from native discovery", async () => { + const control = createControl({ + listPage: vi.fn(async () => ({ + sessions: [{ threadId: "local", status: "idle", archived: false }], + })), + }); + const invoke = vi.fn(async ({ nodeId }) => ({ + payloadJSON: JSON.stringify({ + sessions: [{ threadId: `remote-${nodeId}`, status: "idle", archived: false }], + }), + })); + const { runtime } = createRuntime({ + nodes: [ + { + nodeId: "gateway-node", + displayName: "Gateway node", + gatewayLocal: true, + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + { + nodeId: "remote-node", + displayName: "Remote node", + connected: true, + commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND], + }, + ], + invoke, + }); + + const result = await listCodexSessionCatalog({ + bindingStore: createCodexTestBindingStore(), + config, + runtime, + control, + }); + + expect(result.hosts.map((host) => host.hostId)).toEqual([ + CODEX_LOCAL_SESSION_HOST_ID, + "node:remote-node", + ]); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ nodeId: "remote-node" })); + }); + it("isolates federated host failures while preserving selected healthy hosts", async () => { const control = createControl({ listPage: vi.fn(async () => ({ diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 4d554a67bbd1..6bd16a9acb95 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -627,6 +627,7 @@ async function listCodexSessionCatalog(params: { nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes .filter( (node) => + node.gatewayLocal !== true && node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) && (!requestedHostIds || requestedHostIds.has(`node:${node.nodeId}`)), ) diff --git a/src/gateway/server-methods/nodes.read.ts b/src/gateway/server-methods/nodes.read.ts index 43f9ae7b988f..0b1ee7af73d5 100644 --- a/src/gateway/server-methods/nodes.read.ts +++ b/src/gateway/server-methods/nodes.read.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { listDevicePairing, resolveNodePairingState } from "../../infra/device-pairing.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { projectNodePairing } from "../../infra/node-pairing.js"; +import { resolveLocalNodeId } from "../../node-host/local-id.js"; import type { NodeListNode } from "../../shared/node-list-types.js"; import { replaceRemoteNodeSkills } from "../../skills/runtime/remote-skills.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../skills/runtime/remote.js"; @@ -61,6 +62,7 @@ function listNodesForClient(params: { pairedNodes: ReturnType["paired"]; pendingNodes: ReturnType["pending"]; connectedNodes: readonly NodeSession[]; + localNodeId: string | null; }): NodeListNode[] { const catalog = createKnownNodeCatalog({ pairedDevices: params.pairedDevices, @@ -68,7 +70,9 @@ function listNodesForClient(params: { pendingNodes: params.pendingNodes, connectedNodes: params.connectedNodes, }); - const nodes = listKnownNodes(catalog); + const nodes = listKnownNodes(catalog).map((node) => + node.nodeId === params.localNodeId ? Object.assign({}, node, { gatewayLocal: true }) : node, + ); if (nodeInvokePolicy.canReadPendingNodePairing(params.client)) { return nodes; } @@ -223,12 +227,19 @@ export const nodeReadHandlers: GatewayRequestHandlers = { const devicePairing = await listDevicePairing(); const nodePairing = projectNodePairing(devicePairing.paired); const connectedNodes = listCurrentConnectedNodes(context, devicePairing.paired); + const localNodeId = await resolveLocalNodeId().catch((error: unknown) => { + context.logGateway.warn( + `failed to resolve same-install node-host identity: ${formatErrorMessage(error)}`, + ); + return null; + }); const nodes = listNodesForClient({ client, pairedDevices: devicePairing.paired, pairedNodes: nodePairing.paired, pendingNodes: nodePairing.pending, connectedNodes, + localNodeId, }); const activeNodeId = context.nodeRegistry.getActiveNode(connectedNodes)?.nodeId; const nodesWithPresence = activeNodeId diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 0c7c29b029c6..f3c172ad086a 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1054,7 +1054,7 @@ describe("loadGatewayPlugins", () => { expect(opts.req.method).toBe("node.list"); opts.respond(true, { nodes: [ - { nodeId: "connected", connected: true }, + { nodeId: "connected", connected: true, gatewayLocal: true }, { nodeId: "offline", connected: false }, ], }); @@ -1066,7 +1066,7 @@ describe("loadGatewayPlugins", () => { const result = await runtime.nodes.list({ connected: true }); expect(getLastDispatchedParams()).toStrictEqual({}); - expect(result.nodes).toEqual([{ nodeId: "connected", connected: true }]); + expect(result.nodes).toEqual([{ nodeId: "connected", connected: true, gatewayLocal: true }]); }); test("projects effective node-command policy into the plugin node runtime", async () => { diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index b983231ada70..ac654fa54664 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -17,7 +17,7 @@ import { import { upsertPresence } from "../../../infra/system-presence.js"; import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"; import { loadVoiceWakeConfig } from "../../../infra/voicewake.js"; -import { loadNodeHostConfig } from "../../../node-host/config.js"; +import { resolveLocalNodeId } from "../../../node-host/local-id.js"; import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js"; import { ensureProfileForEmail } from "../../../state/user-profiles.js"; import { @@ -63,16 +63,6 @@ function isReleasedVersion(version: string): boolean { return RELEASED_VERSION_RE.test(version); } -/** - * Lazily resolve the local node host's nodeId from canonical shared SQLite state. - * Process-stable: only changes on `openclaw node install`, which requires restart. - */ -let cachedLocalNodeId: Promise | undefined; -async function resolveLocalNodeId(): Promise { - cachedLocalNodeId ??= loadNodeHostConfig().then((config) => config?.nodeId ?? null); - return await cachedLocalNodeId; -} - function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void { const receiver = (socket as { _receiver?: { _maxPayload?: number } })["_receiver"]; if (receiver) { diff --git a/src/node-host/local-id.test.ts b/src/node-host/local-id.test.ts new file mode 100644 index 000000000000..c030e73a94ed --- /dev/null +++ b/src/node-host/local-id.test.ts @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../test-utils/openclaw-test-state.js"; +import { configureNodeHost } from "./config.js"; +import { resolveLocalNodeId } from "./local-id.js"; + +const states: OpenClawTestState[] = []; + +afterEach(async () => { + closeOpenClawStateDatabaseForTest(); + while (states.length > 0) { + await states.pop()?.cleanup(); + } +}); + +describe("resolveLocalNodeId", () => { + it("reads and process-caches the canonical same-install node id", async () => { + const state = await createOpenClawTestState({ + label: "local-node-id", + layout: "state-only", + }); + states.push(state); + await configureNodeHost({ + candidateNodeId: "gateway-local-node", + fallbackDisplayName: "Gateway node", + gateway: {}, + env: state.env, + nowMs: 1, + }); + + await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node"); + + await configureNodeHost({ + candidateNodeId: "replacement-node", + fallbackDisplayName: "Replacement node", + gateway: {}, + env: state.env, + nowMs: 2, + }); + await expect(resolveLocalNodeId(state.env)).resolves.toBe("gateway-local-node"); + }); + + it("retries after a failed canonical config read", async () => { + const state = await createOpenClawTestState({ + label: "local-node-id-retry", + layout: "state-only", + }); + states.push(state); + const legacyPath = state.statePath("node.json"); + await fs.writeFile(legacyPath, "{}\n", "utf8"); + + await expect(resolveLocalNodeId(state.env)).rejects.toThrow("openclaw doctor --fix"); + + await fs.rm(legacyPath); + await configureNodeHost({ + candidateNodeId: "recovered-local-node", + fallbackDisplayName: "Recovered node", + gateway: {}, + env: state.env, + nowMs: 1, + }); + await expect(resolveLocalNodeId(state.env)).resolves.toBe("recovered-local-node"); + }); +}); diff --git a/src/node-host/local-id.ts b/src/node-host/local-id.ts new file mode 100644 index 000000000000..4128c1cc0d21 --- /dev/null +++ b/src/node-host/local-id.ts @@ -0,0 +1,27 @@ +import { resolveStateDir } from "../config/paths.js"; +import { loadNodeHostConfig } from "./config.js"; + +const localNodeIdByStateDir = new Map>(); + +/** + * Resolve the same-install node host from canonical shared SQLite state. + * Node-host config changes require restart, so this fact stays process-stable. + */ +export async function resolveLocalNodeId( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const stateDir = resolveStateDir(env); + let pending = localNodeIdByStateDir.get(stateDir); + if (!pending) { + pending = loadNodeHostConfig(env).then((config) => config?.nodeId ?? null); + localNodeIdByStateDir.set(stateDir, pending); + } + try { + return await pending; + } catch (error) { + if (localNodeIdByStateDir.get(stateDir) === pending) { + localNodeIdByStateDir.delete(stateDir); + } + throw error; + } +} diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 2236170bc542..b32f231c8b25 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -80,6 +80,8 @@ type RuntimeNodeListResult = { connected?: boolean; caps?: string[]; commands?: string[]; + /** True only for the node host installed alongside this Gateway. */ + gatewayLocal?: boolean; /** Advertised commands currently permitted by Gateway node-command policy. */ invocableCommands?: string[]; nodePluginTools?: NodePluginToolDescriptor[]; diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index 9cb474f4cbf8..0768b6ca61b7 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -10,6 +10,8 @@ export type NodeListNode = { uiVersion?: string; clientId?: string; clientMode?: string; + /** This node host runs from the Gateway's own canonical node-host installation. */ + gatewayLocal?: boolean; remoteIp?: string; deviceFamily?: string; modelIdentifier?: string; diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index 78ab8e5d3318..f9a683e51128 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -22,6 +22,7 @@ import { formatSidebarTimestamp, type CatalogBackingSessionDisplay, type CatalogSessionMenuRequest, + visibleCatalogHosts, } from "./app-sidebar-session-catalogs.ts"; import { renderSidebarSessionSectionHeader } from "./app-sidebar-session-section-header.ts"; import { icons } from "./icons.ts"; @@ -119,15 +120,8 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { const sectionId = `catalog:${catalog.id}`; const collapsed = params.collapsedSections.has(sectionId); const hosts = catalog.hosts; - const visibleHosts: SessionCatalogHost[] = []; - for (const host of hosts) { - const sessions = host.sessions.filter( - (session) => !params.creatorId || session.createdActor?.id === params.creatorId, - ); - if (sessions.length > 0) { - visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions }); - } - } + // Catalog providers own host identity; the sidebar only removes hosts with no visible rows. + const visibleHosts = visibleCatalogHosts(hosts, params.creatorId); const rows = visibleHosts.flatMap((host) => host.sessions.map((session) => ({ host, session })), ); diff --git a/ui/src/components/app-sidebar-session-catalogs.test.ts b/ui/src/components/app-sidebar-session-catalogs.test.ts index 4f99a744f9eb..3b963241cce9 100644 --- a/ui/src/components/app-sidebar-session-catalogs.test.ts +++ b/ui/src/components/app-sidebar-session-catalogs.test.ts @@ -1,7 +1,8 @@ // @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SessionCatalogHost } from "../../../packages/gateway-protocol/src/index.ts"; import { i18n } from "../i18n/index.ts"; -import { formatSidebarTimestamp } from "./app-sidebar-session-catalogs.ts"; +import { formatSidebarTimestamp, visibleCatalogHosts } from "./app-sidebar-session-catalogs.ts"; describe("formatSidebarTimestamp", () => { afterEach(async () => { @@ -31,3 +32,60 @@ describe("formatSidebarTimestamp", () => { expect(formatSidebarTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m"); }); }); + +describe("visibleCatalogHosts", () => { + const session = (threadId: string, name: string) => ({ + threadId, + name, + status: "idle", + archived: false, + canContinue: true, + canArchive: false, + }); + + it("removes empty hosts", () => { + const hosts: SessionCatalogHost[] = [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [session("shared", "Gateway copy")], + }, + { + hostId: "node:empty", + label: "Empty node", + kind: "node", + connected: true, + sessions: [], + }, + ]; + + expect(visibleCatalogHosts(hosts)).toEqual([hosts[0]]); + }); + + it("filters sessions by creator without inferring host identity", () => { + const hosts: SessionCatalogHost[] = [ + { + hostId: "node:remote", + label: "Remote node", + kind: "node", + connected: true, + sessions: [ + { + ...session("mine", "Mine"), + createdActor: { id: "operator:mine", type: "human" }, + }, + { + ...session("theirs", "Theirs"), + createdActor: { id: "operator:theirs", type: "human" }, + }, + ], + }, + ]; + + expect(visibleCatalogHosts(hosts, "operator:mine")).toEqual([ + { ...hosts[0]!, sessions: [hosts[0]!.sessions[0]!] }, + ]); + }); +}); diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 3060a4609dc0..4b8514e68249 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -1,5 +1,6 @@ import type { SessionCatalog, + SessionCatalogHost, SessionCatalogSession, } from "../../../packages/gateway-protocol/src/index.ts"; import type { ApplicationNavigationOptions } from "../app/context.ts"; @@ -42,6 +43,22 @@ export function adoptedCatalogSessionKeys(catalogs: readonly SessionCatalog[]): return keys; } +export function visibleCatalogHosts( + hosts: readonly SessionCatalogHost[], + creatorId?: string | null, +): SessionCatalogHost[] { + const visible: SessionCatalogHost[] = []; + for (const host of hosts) { + const sessions = host.sessions.filter( + (session) => !creatorId || session.createdActor?.id === creatorId, + ); + if (sessions.length > 0) { + visible.push(sessions.length === host.sessions.length ? host : { ...host, sessions }); + } + } + return visible; +} + export type CatalogBackingSessionDisplay = { label: string; subtitle?: string; diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index 98e170ec302f..1ce05493a0c9 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -395,15 +395,7 @@ function renderSessionListBody(params: { : nothing}`; } if (section.id === "work") { - // Keep the Coding header visible beside catalog sections just as it - // was when those sections were nested inside it. - if ( - section.totalRowCount === 0 && - !( - catalogsVisible && - (params.catalogs.catalogs.length > 0 || params.catalogs.refreshStatus.error !== null) - ) - ) { + if (section.totalRowCount === 0) { return nothing; } return renderSessionSection({ host, section }); diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index cd4cc725debf..fb491bad0729 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -207,7 +207,15 @@ function hostGroupedNativeCatalogs() { async function expandCodingSection(page: Page) { const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle'); - await toggle.waitFor({ state: "visible" }); + await page.waitForFunction(() => + Boolean( + document.querySelector('[data-session-section="work"]') ?? + document.querySelector('[data-session-section^="catalog:"]'), + ), + ); + if ((await toggle.count()) === 0) { + return; + } if ((await toggle.getAttribute("aria-expanded")) === "false") { await toggle.click(); } @@ -259,6 +267,9 @@ suite("Claude native session catalog", () => { expect(await gatewayHost.getByText("Gateway Mac", { exact: true }).count()).toBe(0); expect(await gatewayHost.locator(".sidebar-recent-session").count()).toBe(1); expect(await buildHost.locator(".sidebar-recent-session").count()).toBe(1); + expect(await section.getByText(`${catalogLabel} local plan`, { exact: true }).count()).toBe( + 1, + ); } const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index b3a27e180ccc..c48eb0043d38 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -29,9 +29,21 @@ const uiProofArtifactDir = path.join( "native-session-discovery", ); -async function expandCodingSection(page: Page) { +async function expandCodingSection(page: Page, required = false) { const toggle = page.locator('[data-session-section="work"] .sidebar-session-group-toggle'); - await toggle.waitFor({ state: "visible" }); + if (required) { + await toggle.waitFor({ state: "visible" }); + } else { + await page.waitForFunction(() => + Boolean( + document.querySelector('[data-session-section="work"]') ?? + document.querySelector('[data-session-section^="catalog:"]'), + ), + ); + if ((await toggle.count()) === 0) { + return; + } + } if ((await toggle.getAttribute("aria-expanded")) === "false") { await toggle.click(); } @@ -198,7 +210,7 @@ suite("Codex native session catalog", () => { try { await page.goto(`${server.baseUrl}chat`); await page.evaluate(() => document.documentElement.setAttribute("data-theme-mode", "dark")); - await expandCodingSection(page); + await expandCodingSection(page, true); const sessionGroups = page.locator(".sidebar-recent-sessions"); const workSection = sessionGroups.locator(':scope > [data-session-section="work"]'); const liveRows = workSection.locator(":scope > .sidebar-recent-sessions__list"); diff --git a/ui/src/e2e/external-session-catalogs.e2e.test.ts b/ui/src/e2e/external-session-catalogs.e2e.test.ts index eac9974aa3d6..41122cd11a29 100644 --- a/ui/src/e2e/external-session-catalogs.e2e.test.ts +++ b/ui/src/e2e/external-session-catalogs.e2e.test.ts @@ -116,7 +116,6 @@ suite("OpenCode and Pi external session catalogs", () => { }); await page.goto(`${server.baseUrl}chat`); - await page.locator('[data-session-section="work"] .sidebar-session-group-toggle').click(); await page.getByText("OpenCode release review", { exact: true }).click(); await expect.poll(() => page.getByText("OpenCode transcript loaded").count()).toBe(1); await page.getByText("Pi architecture notes", { exact: true }).click(); diff --git a/ui/src/e2e/native-session-sidebar.e2e.test.ts b/ui/src/e2e/native-session-sidebar.e2e.test.ts new file mode 100644 index 000000000000..ff2161a5f635 --- /dev/null +++ b/ui/src/e2e/native-session-sidebar.e2e.test.ts @@ -0,0 +1,139 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const available = canRunPlaywrightChromium(executablePath); +const allowMissing = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const suite = available || !allowMissing ? describe : describe.skip; +const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const collapsedSessionSectionsStorageKey = "openclaw:sidebar:sessions:collapsed-sections"; +const uiProofArtifactDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "native-session-discovery", +); + +let browser: Browser; +let server: ControlUiE2eServer; + +suite("native session sidebar", () => { + beforeAll(async () => { + if (!available) { + throw new Error(`Playwright Chromium is unavailable at ${executablePath}`); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("hides empty native hosts and the empty Coding section", async () => { + const page = await browser.newPage({ + deviceScaleFactor: 2, + viewport: { height: 1100, width: 1440 }, + }); + await page.addInitScript( + (key) => localStorage.removeItem(key), + collapsedSessionSectionsStorageKey, + ); + await installMockGateway(page, { + featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"], + methodResponses: { + "sessions.catalog.list": { + catalogs: [ + { + id: "codex", + label: "Codex", + capabilities: { continueSession: true, archive: true }, + hosts: [ + { + hostId: "gateway:local", + label: "Gateway", + kind: "gateway", + connected: true, + sessions: [ + { + threadId: "thread-shared", + name: "Shared gateway session", + cwd: "/workspace/openclaw", + status: "idle", + archived: false, + canContinue: true, + canArchive: true, + }, + ], + }, + { + hostId: "node:remote", + label: "Remote Workstation", + kind: "node", + connected: true, + nodeId: "remote", + sessions: [ + { + threadId: "thread-remote", + name: "Remote-only session", + cwd: "/workspace/remote", + status: "idle", + archived: false, + canContinue: false, + canArchive: false, + }, + ], + }, + { + hostId: "node:empty", + label: "Empty Workstation", + kind: "node", + connected: true, + nodeId: "empty", + sessions: [], + }, + ], + }, + ], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.evaluate(() => { + document.documentElement.setAttribute("data-theme", "openknot"); + document.documentElement.setAttribute("data-theme-mode", "dark"); + }); + const sessionGroups = page.locator(".sidebar-recent-sessions"); + const section = sessionGroups.locator(':scope > [data-session-section="catalog:codex"]'); + await section.waitFor({ state: "visible" }); + await section.getByText("Shared gateway session", { exact: true }).waitFor(); + await section.getByText("Remote-only session", { exact: true }).waitFor(); + if (captureUiProofEnabled) { + await mkdir(uiProofArtifactDir, { recursive: true }); + await sessionGroups.screenshot({ + animations: "disabled", + path: path.join(uiProofArtifactDir, "08-after-deduplicated-session-hosts.png"), + }); + } + + expect(await sessionGroups.locator(':scope > [data-session-section="work"]').count()).toBe(0); + expect(await section.getByText("Shared gateway session", { exact: true }).count()).toBe(1); + expect(await section.locator('[data-session-catalog-host="node:remote"]').count()).toBe(1); + expect(await section.locator('[data-session-catalog-host="node:empty"]').count()).toBe(0); + } finally { + await page.close(); + } + }); +});