From ff72f287c37e21b233bc919ae2ceda5fc8005e13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 13:56:54 -0400 Subject: [PATCH] fix(gateway): declare viewer presence explicitly instead of deriving it from subscriptions (#116001) * fix(gateway): declare viewer presence explicitly instead of deriving it from subscriptions * refactor(ui): isolate viewer presence lifecycle ownership * chore(protocol): register viewer presence schema owner * fix(ui): keep viewer presence cleared after detach * chore: drop changelog edits from this PR CHANGELOG.md is release-owned; release generation derives entries from merged PRs. Release-note context stays in the PR body. --- .../openclaw/app/gateway/GatewayProtocol.kt | 1 + .../OpenClawProtocol/GatewayModels.swift | 40 ++- .../gateway-protocol/src/schema-modules.ts | 1 + ...ocol-schema-fragment-sessions-lifecycle.ts | 3 + .../src/schema/sessions-row.test.ts | 8 +- .../src/schema/sessions-row.ts | 2 + .../src/schema/sessions-viewer-presence.ts | 24 ++ .../src/validator-registry.ts | 4 + scripts/check-protocol-registry.mjs | 4 +- .../methods/core-descriptors.since.test.ts | 1 + src/gateway/methods/core-descriptors.ts | 5 + src/gateway/server-lifecycle.ts | 58 +--- src/gateway/server-live-state.ts | 3 + src/gateway/server-methods-list.test.ts | 4 + src/gateway/server-methods.ts | 1 + .../server-methods/sessions-subscriptions.ts | 39 +++ .../server-methods/sessions-viewers.test.ts | 106 +++++++ src/gateway/server-methods/shared-types.ts | 3 + src/gateway/server-request-context.test.ts | 7 +- src/gateway/server-request-context.ts | 4 +- src/gateway/session-message-events.test.ts | 118 +++++--- src/gateway/session-utils-contracts.ts | 7 +- src/gateway/session-utils-creators.test.ts | 11 +- src/gateway/session-utils-list.ts | 44 +-- src/gateway/session-utils-projection.ts | 17 +- src/gateway/session-utils-row.ts | 35 ++- src/gateway/session-viewer-presence.test.ts | 44 +++ src/gateway/session-viewer-presence.ts | 70 +++++ src/shared/session-types.ts | 2 +- ui/src/app/user-profile.ts | 52 ---- .../app-sidebar-session-menu-renderers.ts | 17 +- .../app-sidebar-session-navigation-logic.ts | 2 +- .../app-sidebar-session-row-render.ts | 14 +- .../components/session-leading-indicator.ts | 4 +- ui/src/components/session-owner-chip.ts | 41 ++- ui/src/components/session-owner-identity.ts | 59 ---- ui/src/components/sidebar-menus-render.ts | 3 - ui/src/components/viewer-facepile.test.ts | 79 ++++- ui/src/components/viewer-facepile.ts | 49 ++-- ui/src/lib/session-viewer-presence.test.ts | 273 ++++++++++++++++++ ui/src/lib/session-viewer-presence.ts | 234 +++++++++++++++ ui/src/pages/chat/chat-page.test.ts | 102 +++++++ ui/src/pages/chat/chat-page.ts | 24 +- ui/src/pages/chat/chat-pane-header.ts | 12 +- ui/src/pages/chat/chat-pane-render.ts | 16 +- ui/src/pages/chat/chat-viewer-presence.ts | 31 ++ .../chat/components/chat-pane-header.test.ts | 16 +- .../pages/chat/components/chat-pane-header.ts | 3 - ui/src/pages/chat/split-layout.ts | 9 + .../app-sidebar-cases/session-ownership.ts | 50 +++- 50 files changed, 1398 insertions(+), 358 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/sessions-viewer-presence.ts create mode 100644 src/gateway/server-methods/sessions-viewers.test.ts create mode 100644 src/gateway/session-viewer-presence.test.ts create mode 100644 src/gateway/session-viewer-presence.ts delete mode 100644 ui/src/components/session-owner-identity.ts create mode 100644 ui/src/lib/session-viewer-presence.test.ts create mode 100644 ui/src/lib/session-viewer-presence.ts create mode 100644 ui/src/pages/chat/chat-viewer-presence.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 5636740d5a55..379554d1a630 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -334,6 +334,7 @@ enum class GatewayMethod( SessionsUnsubscribe("sessions.unsubscribe"), SessionsMessagesSubscribe("sessions.messages.subscribe"), SessionsMessagesUnsubscribe("sessions.messages.unsubscribe"), + SessionsViewersSet("sessions.viewers.set"), SessionsPreview("sessions.preview"), SessionsDescribe("sessions.describe"), SessionsCompactionList("sessions.compaction.list"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 0b0e6a125637..2ea3826510c1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4846,21 +4846,25 @@ public struct SessionCreatedActor: Codable, Sendable { public let type: AnyCodable public let id: String? public let label: String? + public let avatarurl: String? public init( type: AnyCodable, id: String? = nil, - label: String? = nil) + label: String? = nil, + avatarurl: String? = nil) { self.type = type self.id = id self.label = label + self.avatarurl = avatarurl } private enum CodingKeys: String, CodingKey { case type case id case label + case avatarurl = "avatarUrl" } } @@ -5304,21 +5308,25 @@ public struct SessionSharingIdentity: Codable, Sendable { public let type: AnyCodable public let id: String public let label: String? + public let avatarurl: String? public init( type: AnyCodable, id: String, - label: String? = nil) + label: String? = nil, + avatarurl: String? = nil) { self.type = type self.id = id self.label = label + self.avatarurl = avatarurl } private enum CodingKeys: String, CodingKey { case type case id case label + case avatarurl = "avatarUrl" } } @@ -7494,6 +7502,34 @@ public struct SessionsMessagesUnsubscribeParams: Codable, Sendable { } } +public struct SessionsViewerPresenceSetParams: Codable, Sendable { + public let sessionkeys: [String] + + public init( + sessionkeys: [String]) + { + self.sessionkeys = sessionkeys + } + + private enum CodingKeys: String, CodingKey { + case sessionkeys = "sessionKeys" + } +} + +public struct SessionsViewerPresenceSetResult: Codable, Sendable { + public let sessionkeys: [String] + + public init( + sessionkeys: [String]) + { + self.sessionkeys = sessionkeys + } + + private enum CodingKeys: String, CodingKey { + case sessionkeys = "sessionKeys" + } +} + public struct SessionsAbortParams: Codable, Sendable { public let key: String? public let runid: String? diff --git a/packages/gateway-protocol/src/schema-modules.ts b/packages/gateway-protocol/src/schema-modules.ts index 8dcb7d411aeb..1c9de4fac2b6 100644 --- a/packages/gateway-protocol/src/schema-modules.ts +++ b/packages/gateway-protocol/src/schema-modules.ts @@ -33,6 +33,7 @@ export * from "./schema/secrets.js"; export * from "./schema/session-placement.js"; export * from "./schema/session-discussion.js"; export * from "./schema/sessions.js"; +export * from "./schema/sessions-viewer-presence.js"; export * from "./schema/sessions-sharing.js"; export * from "./schema/sessions-suggestions.js"; export * from "./schema/sessions-catalog.js"; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts index d87eaedde23b..d57660c63fed 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-sessions-lifecycle.ts @@ -1,3 +1,4 @@ +import * as viewerPresence from "./sessions-viewer-presence.js"; import * as sessions from "./sessions.js"; export const SessionLifecycleProtocolSchemas = { @@ -42,6 +43,8 @@ export const SessionLifecycleProtocolSchemas = { SessionsSendParams: sessions.SessionsSendParamsSchema, SessionsMessagesSubscribeParams: sessions.SessionsMessagesSubscribeParamsSchema, SessionsMessagesUnsubscribeParams: sessions.SessionsMessagesUnsubscribeParamsSchema, + SessionsViewerPresenceSetParams: viewerPresence.SessionsViewerPresenceSetParamsSchema, + SessionsViewerPresenceSetResult: viewerPresence.SessionsViewerPresenceSetResultSchema, SessionsAbortParams: sessions.SessionsAbortParamsSchema, SessionsPatchParams: sessions.SessionsPatchParamsSchema, SessionsPluginPatchParams: sessions.SessionsPluginPatchParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-row.test.ts b/packages/gateway-protocol/src/schema/sessions-row.test.ts index 3c81af49c8eb..31449e63d35e 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.test.ts @@ -8,7 +8,12 @@ describe("SessionRowSchema", () => { key: "agent:main:main", kind: "global", activeLeafEntryId: "leaf-rendered", - createdActor: { type: "human", id: "profile-ada", label: "Ada" }, + createdActor: { + type: "human", + id: "profile-ada", + label: "Ada", + avatarUrl: "/api/users/profile-ada/avatar?v=7", + }, archivedBy: { type: "human", id: "profile-bob", label: "Bob" }, visibility: "suggest", sharingRole: "owner", @@ -20,6 +25,7 @@ describe("SessionRowSchema", () => { expect(Value.Check(SessionRowSchema, { ...roundTripped, activeLeafEntryId: null })).toBe(true); expect(roundTripped).toMatchObject({ activeLeafEntryId: "leaf-rendered", + createdActor: { avatarUrl: "/api/users/profile-ada/avatar?v=7" }, archivedBy: { type: "human", id: "profile-bob", label: "Bob" }, visibility: "suggest", sharingRole: "owner", diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts index a5a0b2828fd5..ea035ce32b87 100644 --- a/packages/gateway-protocol/src/schema/sessions-row.ts +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -18,6 +18,8 @@ export const SessionCreatedActorSchema = closedObject({ type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]), id: Type.Optional(NonEmptyString), label: Type.Optional(NonEmptyString), + /** Durable profile avatar route; absent for actors without a stored profile avatar. */ + avatarUrl: Type.Optional(NonEmptyString), }); /** Stable Gateway session row fields; mutation envelopes may add null tombstones. */ diff --git a/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts b/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts new file mode 100644 index 000000000000..8419ba164cdb --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-viewer-presence.ts @@ -0,0 +1,24 @@ +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { ChatSendSessionKeyString } from "./primitives.js"; + +/** Maximum sessions one connection may declare as concurrently visible. */ +export const SESSION_VIEWER_PRESENCE_MAX_KEYS = 32; + +/** Replaces the sessions this connection is currently rendering. */ +export const SessionsViewerPresenceSetParamsSchema = closedObject({ + sessionKeys: Type.Array(ChatSendSessionKeyString, { + maxItems: SESSION_VIEWER_PRESENCE_MAX_KEYS, + }), +}); + +/** Canonical session keys retained for this connection's viewer presence. */ +export const SessionsViewerPresenceSetResultSchema = closedObject({ + sessionKeys: Type.Array(ChatSendSessionKeyString, { + maxItems: SESSION_VIEWER_PRESENCE_MAX_KEYS, + }), +}); + +export type SessionsViewerPresenceSetParams = Static; +export type SessionsViewerPresenceSetResult = Static; diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index 9e30e5c1ba2d..b051bc5fa943 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -132,6 +132,7 @@ import { SessionsReclaimParamsSchema, SessionsMessagesSubscribeParamsSchema, SessionsMessagesUnsubscribeParamsSchema, + SessionsViewerPresenceSetParamsSchema, SessionsAbortParamsSchema, SessionsPatchParamsSchema, SessionsPluginPatchParamsSchema, @@ -502,6 +503,9 @@ export const validateSessionsMessagesSubscribeParams = lazyCompile( export const validateSessionsMessagesUnsubscribeParams = lazyCompile( SessionsMessagesUnsubscribeParamsSchema, ); +export const validateSessionsViewerPresenceSetParams = lazyCompile( + SessionsViewerPresenceSetParamsSchema, +); export const validateSessionsAbortParams = lazyCompile(SessionsAbortParamsSchema); export const validateSessionsPatchParams = lazyCompile(SessionsPatchParamsSchema); export const validateSessionsPluginPatchParams = lazyCompile(SessionsPluginPatchParamsSchema); diff --git a/scripts/check-protocol-registry.mjs b/scripts/check-protocol-registry.mjs index c478320944ed..a9f352207b3d 100644 --- a/scripts/check-protocol-registry.mjs +++ b/scripts/check-protocol-registry.mjs @@ -111,8 +111,8 @@ const ownerModules = [ ...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu), ].map((match) => match[1]); check( - ownerModules.length === 51 && new Set(ownerModules).size === ownerModules.length, - "schema-modules.ts must contain one unique 51-module owner list", + ownerModules.length === 52 && new Set(ownerModules).size === ownerModules.length, + "schema-modules.ts must contain one unique 52-module owner list", ); check( schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length, diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 936b38bcfb43..5e8e7334ba54 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -46,6 +46,7 @@ const CURRENT_TRAIN_METHODS = [ "environments.destroy", "sessions.dispatch", "sessions.reclaim", + "sessions.viewers.set", "sessions.catalog.list", "sessions.catalog.read", "sessions.catalog.continue", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 4c7c9ba3cd43..686e3009145d 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -215,6 +215,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "sessions.unsubscribe", scope: "operator.read", since: "<=2026.7" }, { name: "sessions.messages.subscribe", scope: "operator.read", since: "<=2026.7" }, { name: "sessions.messages.unsubscribe", scope: "operator.read", since: "<=2026.7" }, + { + name: "sessions.viewers.set", + scope: "operator.read", + since: "2026.7", + }, { name: "sessions.preview", scope: "operator.read", since: "<=2026.7" }, { name: "sessions.describe", scope: "operator.read", since: "<=2026.7" }, { name: "sessions.compaction.list", scope: "operator.read", since: "<=2026.7" }, diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index bb5527d039ff..cb17ae68b3f7 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -39,6 +39,7 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { broadcastPresenceSnapshot } from "./server/presence-events.js"; +import { createSessionViewerPresenceDeclarations } from "./session-viewer-presence.js"; type GatewayRuntimePreparation = Awaited>; type GatewayLogger = ReturnType; @@ -146,53 +147,13 @@ export async function prepareGatewayLifecycle(params: { completeControlUiDeviceAuthMigrationForEffectiveOperator, ); workerGatewayEndpoint.resolve = getWorkerIngressEndpoint; - const presenceWatchedSessions = (connId: string): string[] => { - // Presence snapshots stay small even if a long-lived client accumulates - // subscriptions. Keep only the 32 most recently subscribed session keys. - return [...sessionMessageSubscribers.getForConnection(connId)].slice(-32).toSorted(); - }; - const updateWatchedSessionsPresence = (connId: string, previous: readonly string[]) => { - const watchedSessions = presenceWatchedSessions(connId); - if ( - watchedSessions.length === previous.length && - watchedSessions.every((key, index) => key === previous[index]) - ) { - return; - } - const client = [...clients].find((candidate) => candidate.connId === connId); - if (!client?.presenceKey) { - return; - } - upsertPresence(client.presenceKey, { - watchedSessions: watchedSessions.length > 0 ? watchedSessions : undefined, - }); - broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); - }; const subscribeSessionMessageEvents: GatewayRequestContext["subscribeSessionMessageEvents"] = ( connId, sessionKey, options, - ) => { - const previous = presenceWatchedSessions(connId); - const rollback = sessionMessageSubscribers.subscribe(connId, sessionKey, options); - updateWatchedSessionsPresence(connId, previous); - if (!rollback) { - return undefined; - } - const rollbackPresence = (() => { - const rollbackPrevious = presenceWatchedSessions(connId); - rollback(); - updateWatchedSessionsPresence(connId, rollbackPrevious); - }) as NonNullable>; - rollbackPresence.commit = () => rollback.commit(); - return rollbackPresence; - }; + ) => sessionMessageSubscribers.subscribe(connId, sessionKey, options); const unsubscribeSessionMessageEvents: GatewayRequestContext["unsubscribeSessionMessageEvents"] = - (connId, sessionKey) => { - const previous = presenceWatchedSessions(connId); - sessionMessageSubscribers.unsubscribe(connId, sessionKey); - updateWatchedSessionsPresence(connId, previous); - }; + (connId, sessionKey) => sessionMessageSubscribers.unsubscribe(connId, sessionKey); const restartRecoveryCandidates = new Map(); const { createGatewayNodeSessionRuntime } = await import("./server-node-session-runtime.js"); const { @@ -288,6 +249,18 @@ export async function prepareGatewayLifecycle(params: { runtimeState.controlUiSessionPullRequests = createControlUiSessionPullRequestSubscriptions({ broadcastToConnIds, }); + runtimeState.sessionViewerPresence = createSessionViewerPresenceDeclarations({ + onReplace: (connId, sessionKeys) => { + const client = [...clients].find((candidate) => candidate.connId === connId); + if (!client?.presenceKey) { + return; + } + upsertPresence(client.presenceKey, { + watchedSessions: sessionKeys.length > 0 ? [...sessionKeys] : undefined, + }); + broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); + }, + }); deps.cron = runtimeState.cronState.cron; const pluginHostServices = { get cron() { @@ -328,6 +301,7 @@ export async function prepareGatewayLifecycle(params: { const markClosePreludeStarted = () => { lifecycle.closePreludeStarted = true; runtimeState.controlUiSessionPullRequests?.stop(); + runtimeState.sessionViewerPresence?.stop(); unsubscribeEffectiveOperatorPairing(); startupState.dispatchReady = false; gatewayInstanceRuntimeRef.current?.close(); diff --git a/src/gateway/server-live-state.ts b/src/gateway/server-live-state.ts index 8ae3748fa6fc..847f08e6099d 100644 --- a/src/gateway/server-live-state.ts +++ b/src/gateway/server-live-state.ts @@ -9,6 +9,7 @@ import { type GatewayServerMutableState, } from "./server-runtime-handles.js"; import type { HookClientIpConfig } from "./server/hooks-request-handler.js"; +import type { createSessionViewerPresenceDeclarations } from "./session-viewer-presence.js"; /** Mutable gateway server state shared across request contexts. */ export type GatewayServerLiveState = GatewayServerMutableState & { @@ -16,6 +17,7 @@ export type GatewayServerLiveState = GatewayServerMutableState & { hookClientIpConfig: HookClientIpConfig; cronState: GatewayCronState; controlUiSessionPullRequests?: ReturnType; + sessionViewerPresence?: ReturnType; pluginServices: PluginServicesHandle | null; gatewayMethods: string[]; }; @@ -33,6 +35,7 @@ export function createGatewayServerLiveState(params: { hookClientIpConfig: params.hookClientIpConfig, cronState: params.cronState, controlUiSessionPullRequests: undefined, + sessionViewerPresence: undefined, pluginServices: null, gatewayMethods: params.gatewayMethods, }; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 55b99a37409b..4b4bfe4fdb61 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -119,6 +119,10 @@ describe("listGatewayMethods", () => { expect(GATEWAY_EVENTS).toContain("controlUi.sessionPullRequests.changed"); }); + it("advertises explicit session viewer presence", () => { + expect(listGatewayMethods()).toContain("sessions.viewers.set"); + }); + it("advertises session workspace reveal", () => { expect(listGatewayMethods()).toContain("sessions.files.reveal"); expect(coreGatewayHandlers["sessions.files.reveal"]).toBeTypeOf("function"); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1e67d649cc0d..629aeac031f8 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -739,6 +739,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { "sessions.cleanup", "sessions.subscribe", "sessions.unsubscribe", + "sessions.viewers.set", "sessions.messages.subscribe", "sessions.messages.unsubscribe", "sessions.preview", diff --git a/src/gateway/server-methods/sessions-subscriptions.ts b/src/gateway/server-methods/sessions-subscriptions.ts index e17875bef681..a2b1964d8c27 100644 --- a/src/gateway/server-methods/sessions-subscriptions.ts +++ b/src/gateway/server-methods/sessions-subscriptions.ts @@ -4,6 +4,7 @@ import { errorShape, validateSessionsMessagesSubscribeParams, validateSessionsMessagesUnsubscribeParams, + validateSessionsViewerPresenceSetParams, } from "../../../packages/gateway-protocol/src/index.js"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { canReviewOperatorApproval } from "../operator-approval-authorization.js"; @@ -30,6 +31,44 @@ export const sessionSubscriptionHandlers: GatewayRequestHandlers = { } respond(true, { subscribed: false }, undefined); }, + "sessions.viewers.set": ({ params, client, context, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsViewerPresenceSetParams, + "sessions.viewers.set", + respond, + ) + ) { + return; + } + const connId = client?.connId?.trim(); + const declarations = context.sessionViewerPresence; + if (!connId || !declarations) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "session viewer presence unavailable"), + ); + return; + } + const cfg = context.getRuntimeConfig(); + const canonicalKeys: string[] = []; + for (const rawKey of params.sessionKeys) { + const trimmed = rawKey.trim(); + if (!trimmed) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid sessions.viewers.set params"), + ); + return; + } + canonicalKeys.push(resolveSessionStoreKey({ cfg, sessionKey: trimmed })); + } + const sessionKeys = declarations.replace(connId, canonicalKeys); + respond(true, { sessionKeys }, undefined); + }, "sessions.messages.subscribe": ({ params, client, context, respond }) => { if ( !assertValidParams( diff --git a/src/gateway/server-methods/sessions-viewers.test.ts b/src/gateway/server-methods/sessions-viewers.test.ts new file mode 100644 index 000000000000..c19b70b6a67b --- /dev/null +++ b/src/gateway/server-methods/sessions-viewers.test.ts @@ -0,0 +1,106 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { describe, expect, it, vi } from "vitest"; +import { SESSION_VIEWER_PRESENCE_MAX_KEYS } from "../../../packages/gateway-protocol/src/schema/sessions-viewer-presence.js"; +import { sessionsHandlers } from "./sessions.js"; +import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js"; + +async function declare(params: { + body: Record; + connId?: string; + context: GatewayRequestContext; +}) { + const respond = vi.fn(); + await expectDefined( + sessionsHandlers["sessions.viewers.set"], + 'sessionsHandlers["sessions.viewers.set"] test invariant', + )({ + req: { id: "req-viewers", method: "sessions.viewers.set" } as never, + params: params.body, + respond, + context: params.context, + client: params.connId === undefined ? null : ({ connId: params.connId } as never), + isWebchatConnect: () => false, + } satisfies GatewayRequestHandlerOptions); + return respond; +} + +function createContext() { + const replace = vi.fn((_connId: string, sessionKeys: readonly string[]) => + [...new Set(sessionKeys)].toSorted(), + ); + const context = { + getRuntimeConfig: () => ({ + agents: { list: [{ id: "work", default: true }] }, + session: { mainKey: "home" }, + }), + sessionViewerPresence: { replace }, + } as unknown as GatewayRequestContext; + return { context, replace }; +} + +describe("sessions.viewers.set", () => { + it("canonicalizes and atomically replaces the connection set", async () => { + const { context, replace } = createContext(); + const respond = await declare({ + body: { sessionKeys: [" main ", "agent:work:other", "main"] }, + connId: " conn-viewer ", + context, + }); + + expect(replace).toHaveBeenCalledWith("conn-viewer", [ + "agent:work:home", + "agent:work:other", + "agent:work:home", + ]); + expect(respond).toHaveBeenCalledWith( + true, + { sessionKeys: ["agent:work:home", "agent:work:other"] }, + undefined, + ); + }); + + it("accepts an empty declaration and rejects malformed or oversized sets", async () => { + const { context, replace } = createContext(); + const empty = await declare({ body: { sessionKeys: [] }, connId: "conn-viewer", context }); + expect(empty).toHaveBeenCalledWith(true, { sessionKeys: [] }, undefined); + + const whitespace = await declare({ + body: { sessionKeys: [" "] }, + connId: "conn-viewer", + context, + }); + expect(whitespace).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + + const oversized = await declare({ + body: { + sessionKeys: Array.from( + { length: SESSION_VIEWER_PRESENCE_MAX_KEYS + 1 }, + (_, index) => `session-${index}`, + ), + }, + connId: "conn-viewer", + context, + }); + expect(oversized).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(replace).toHaveBeenCalledTimes(1); + }); + + it("requires a live connection and declaration owner", async () => { + const context = { getRuntimeConfig: () => ({}) } as unknown as GatewayRequestContext; + const respond = await declare({ body: { sessionKeys: [] }, context }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE" }), + ); + }); +}); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 5d8525423242..030679f3b2b6 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -161,6 +161,9 @@ export type GatewayRequestContext = { controlUiSessionPullRequests?: ReturnType< typeof import("../control-ui-session-pr-subscriptions.js").createControlUiSessionPullRequestSubscriptions >; + sessionViewerPresence?: ReturnType< + typeof import("../session-viewer-presence.js").createSessionViewerPresenceDeclarations + >; sessionCompanion?: import("../session-companion.js").SessionCompanionService; sessionObserver?: SessionObserverService; notifyPluginMetadataChanged: () => void; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 0c3bc1142636..f7564a7cd0d9 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -129,19 +129,24 @@ function makeGatewayClient(params: { } describe("createGatewayRequestContext", () => { - it("cleans connection-scoped PR watches with the other session subscriptions", () => { + it("cleans connection-scoped replace-sets with the other session subscriptions", () => { const unsubscribeAllSessionEvents = vi.fn(); const unsubscribePullRequests = vi.fn(); + const unsubscribeViewerPresence = vi.fn(); const params = makeContextParams({ unsubscribeAllSessionEvents }); params.runtimeState.controlUiSessionPullRequests = { unsubscribe: unsubscribePullRequests, } as never; + params.runtimeState.sessionViewerPresence = { + unsubscribe: unsubscribeViewerPresence, + } as never; const context = createGatewayRequestContext(params); context.unsubscribeAllSessionEvents("conn-control-ui"); expect(unsubscribeAllSessionEvents).toHaveBeenCalledWith("conn-control-ui"); expect(unsubscribePullRequests).toHaveBeenCalledWith("conn-control-ui"); + expect(unsubscribeViewerPresence).toHaveBeenCalledWith("conn-control-ui"); }); it("reads cron state live from runtime state", () => { diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 0cb442a6b777..ff7af46df3f6 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -24,7 +24,7 @@ type GatewayRequestContextParams = { deps: GatewayRequestContext["deps"]; runtimeState: Pick< GatewayServerLiveState, - "cronState" | "configReloader" | "controlUiSessionPullRequests" + "cronState" | "configReloader" | "controlUiSessionPullRequests" | "sessionViewerPresence" >; getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"]; sessionCompanion: SessionCompanionService; @@ -164,6 +164,7 @@ export function createGatewayRequestContext( }, getRuntimeConfig: params.getRuntimeConfig, controlUiSessionPullRequests: params.runtimeState.controlUiSessionPullRequests, + sessionViewerPresence: params.runtimeState.sessionViewerPresence, sessionCompanion: params.sessionCompanion, sessionObserver: params.sessionObserver, notifyPluginMetadataChanged: () => @@ -323,6 +324,7 @@ export function createGatewayRequestContext( params.unsubscribeAllSessionEvents(connId); // PR replace-sets share this websocket cleanup boundary with session events. params.runtimeState.controlUiSessionPullRequests?.unsubscribe(connId); + params.runtimeState.sessionViewerPresence?.unsubscribe(connId); }, getSessionEventSubscriberConnIds: params.getSessionEventSubscriberConnIds, registerToolEventRecipient: params.registerToolEventRecipient, diff --git a/src/gateway/session-message-events.test.ts b/src/gateway/session-message-events.test.ts index 4d7e8a4825d2..22d0930ff84e 100644 --- a/src/gateway/session-message-events.test.ts +++ b/src/gateway/session-message-events.test.ts @@ -11,6 +11,7 @@ import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, } from "../../packages/gateway-protocol/src/client-info.js"; +import { SESSION_VIEWER_PRESENCE_MAX_KEYS } from "../../packages/gateway-protocol/src/schema/sessions-viewer-presence.js"; import { SUBAGENT_ENDED_REASON_ERROR } from "../agents/subagent-lifecycle-events.js"; import { createSubagentRegistryLifecycleController } from "../agents/subagent-registry-lifecycle.js"; import type { SubagentRunRecord } from "../agents/subagent-registry.types.js"; @@ -181,7 +182,7 @@ function expectRecordFields(value: unknown, expected: Record): } describe("session.message websocket events", () => { - test("projects watched sessions into per-connection presence", async () => { + test("publishes only the explicit per-connection viewer replace-set", async () => { const observerWs = await harness.openWs(); const watchedWs = await harness.openWs(); const instanceId = "presence-watched-sessions"; @@ -216,50 +217,97 @@ describe("session.message websocket events", () => { (entry as { instanceId?: unknown }).instanceId === instanceId, ); }; - const firstKey = "agent:main:watch-00"; - const subscribePresence = onceMessage(observerWs, (message) => { + const declaredKeys = ["agent:main:watch-00", "agent:main:watch-01"]; + const declaredPresence = onceMessage(observerWs, (message) => { const entry = findWatchedEntry(message); - return Array.isArray(entry?.watchedSessions) && entry.watchedSessions.includes(firstKey); + return ( + Array.isArray(entry?.watchedSessions) && + JSON.stringify(entry.watchedSessions) === JSON.stringify(declaredKeys) + ); }); - const firstSubscribe = await rpcReq(watchedWs, "sessions.messages.subscribe", { - key: firstKey, + const declaration = await rpcReq(watchedWs, "sessions.viewers.set", { + sessionKeys: [declaredKeys[1], declaredKeys[0], declaredKeys[1]], }); - expect(firstSubscribe.ok).toBe(true); - const subscribedEvent = await subscribePresence; - const subscribedEntry = findWatchedEntry(subscribedEvent); - expect(subscribedEntry?.watchedSessions).toEqual([firstKey]); - expect(subscribedEntry?.user).toBeUndefined(); - expect(subscribedEvent.stateVersion?.presence).toBeGreaterThan(initialPresenceVersion ?? 0); + expect(declaration).toMatchObject({ ok: true, payload: { sessionKeys: declaredKeys } }); + const declaredEvent = await declaredPresence; + const declaredEntry = findWatchedEntry(declaredEvent); + expect(declaredEntry?.watchedSessions).toEqual(declaredKeys); + expect(declaredEntry?.user).toBeUndefined(); + expect(declaredEvent.stateVersion?.presence).toBeGreaterThan(initialPresenceVersion ?? 0); - const remainingKeys = Array.from( - { length: 33 }, - (_, index) => `agent:main:watch-${String(index + 1).padStart(2, "0")}`, + // Sidebar narration owns message subscriptions for running rows, but those + // transport watches must never impersonate viewer presence. + const narrationKeys = Array.from( + { length: 6 }, + (_, index) => `agent:main:narration-${index}`, ); - for (const key of remainingKeys) { + for (const key of narrationKeys) { const response = await rpcReq(watchedWs, "sessions.messages.subscribe", { key }); expect(response.ok).toBe(true); } - const presenceResponse = await rpcReq(observerWs, "system-presence", {}); - const presence = presenceResponse.payload as unknown as Array>; - const cappedEntry = presence.find((entry) => entry.instanceId === instanceId); - const expectedCappedKeys = remainingKeys.slice(-32).toSorted(); - expect(cappedEntry?.watchedSessions).toEqual(expectedCappedKeys); + const afterNarration = await rpcReq(observerWs, "system-presence", {}); + expect( + (afterNarration.payload as unknown as Array>).find( + (entry) => entry.instanceId === instanceId, + )?.watchedSessions, + ).toEqual(declaredKeys); - const removedKey = "agent:main:watch-10"; - const unsubscribePresence = onceMessage(observerWs, (message) => { + // A failed message release cannot change the independently declared set. + const failedUnsubscribe = await rpcReq(watchedWs, "sessions.messages.unsubscribe", { + key: "", + }); + expect(failedUnsubscribe.ok).toBe(false); + const afterFailedUnsubscribe = await rpcReq(observerWs, "system-presence", {}); + expect( + (afterFailedUnsubscribe.payload as unknown as Array>).find( + (entry) => entry.instanceId === instanceId, + )?.watchedSessions, + ).toEqual(declaredKeys); + + const replacementKey = "agent:main:replacement"; + const replacementPresence = onceMessage(observerWs, (message) => { const entry = findWatchedEntry(message); - return Array.isArray(entry?.watchedSessions) && !entry.watchedSessions.includes(removedKey); + return Array.isArray(entry?.watchedSessions) && entry.watchedSessions[0] === replacementKey; }); - const unsubscribe = await rpcReq(watchedWs, "sessions.messages.unsubscribe", { - key: removedKey, + const replacement = await rpcReq(watchedWs, "sessions.viewers.set", { + sessionKeys: [replacementKey], }); - expect(unsubscribe.ok).toBe(true); - const unsubscribedEvent = await unsubscribePresence; - expect(findWatchedEntry(unsubscribedEvent)?.watchedSessions).not.toContain(removedKey); - const subscribedPresenceVersion = subscribedEvent.stateVersion?.presence; - expect(unsubscribedEvent.stateVersion?.presence).toBeGreaterThan( - typeof subscribedPresenceVersion === "number" ? subscribedPresenceVersion : 0, - ); + expect(replacement).toMatchObject({ ok: true, payload: { sessionKeys: [replacementKey] } }); + const replacementEvent = await replacementPresence; + expect(findWatchedEntry(replacementEvent)?.watchedSessions).toEqual([replacementKey]); + + const oversized = await rpcReq(watchedWs, "sessions.viewers.set", { + sessionKeys: Array.from( + { length: SESSION_VIEWER_PRESENCE_MAX_KEYS + 1 }, + (_, index) => `agent:main:oversized-${index}`, + ), + }); + expect(oversized.ok).toBe(false); + const afterOversized = await rpcReq(observerWs, "system-presence", {}); + expect( + (afterOversized.payload as unknown as Array>).find( + (entry) => entry.instanceId === instanceId, + )?.watchedSessions, + ).toEqual([replacementKey]); + + const hiddenPresence = onceMessage(observerWs, (message) => { + const entry = findWatchedEntry(message); + return entry !== undefined && entry.watchedSessions === undefined; + }); + expect( + await rpcReq(watchedWs, "sessions.viewers.set", { + sessionKeys: [], + }), + ).toMatchObject({ ok: true, payload: { sessionKeys: [] } }); + const hiddenEvent = await hiddenPresence; + expect(findWatchedEntry(hiddenEvent)?.watchedSessions).toBeUndefined(); + + const redeclaredPresence = onceMessage(observerWs, (message) => { + const entry = findWatchedEntry(message); + return Array.isArray(entry?.watchedSessions) && entry.watchedSessions[0] === replacementKey; + }); + await rpcReq(watchedWs, "sessions.viewers.set", { sessionKeys: [replacementKey] }); + await redeclaredPresence; const disconnectPresence = onceMessage(observerWs, (message) => { const entry = findWatchedEntry(message); @@ -267,9 +315,9 @@ describe("session.message websocket events", () => { }); watchedWs.close(); const disconnectedEvent = await disconnectPresence; - const unsubscribedPresenceVersion = unsubscribedEvent.stateVersion?.presence; + const hiddenPresenceVersion = hiddenEvent.stateVersion?.presence; expect(disconnectedEvent.stateVersion?.presence).toBeGreaterThan( - typeof unsubscribedPresenceVersion === "number" ? unsubscribedPresenceVersion : 0, + typeof hiddenPresenceVersion === "number" ? hiddenPresenceVersion : 0, ); } finally { observerWs.close(); diff --git a/src/gateway/session-utils-contracts.ts b/src/gateway/session-utils-contracts.ts index 19e4fe0c125c..14d44c851f9a 100644 --- a/src/gateway/session-utils-contracts.ts +++ b/src/gateway/session-utils-contracts.ts @@ -8,6 +8,11 @@ import type { ThinkLevel, listThinkingLevelOptions } from "../auto-reply/thinkin import type { SessionAcpMeta, SessionEntry } from "../config/sessions.js"; import type { ModelCostConfig } from "../utils/usage-format.js"; +export type SessionActorProfileIdentity = { + label?: string; + avatarUrl?: string; +}; + export type SessionListRowContext = { subagentRuns: ReturnType; storeChildSessionsByKey: Map; @@ -21,7 +26,7 @@ export type SessionListRowContext = { >; displayModelIdentityByKey: Map; modelCostConfigByModelRef: Map; - userProfileLabelById: Map; + userProfileIdentityById: Map; acpSessionMetaByEntry: Map; }; diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts index d66b8f9e01b5..bad7a1a5af0d 100644 --- a/src/gateway/session-utils-creators.test.ts +++ b/src/gateway/session-utils-creators.test.ts @@ -9,6 +9,8 @@ const getUserProfileListItem = vi.hoisted(() => vi.fn((profileId: string) => ({ id: profileId, displayName: profileId === "profile-ada" ? "Ada" : "Bob", + hasAvatar: profileId === "profile-ada", + updatedAt: 42, })), ); @@ -44,13 +46,18 @@ it("returns the complete deterministic creator facet independently of pagination expect(result.count).toBe(1); expect(result.totalCount).toBe(2); expect(result.creators).toEqual([ - { id: "profile-ada", label: "Ada" }, + { + id: "profile-ada", + label: "Ada", + avatarUrl: "/api/users/profile-ada/avatar?v=42", + }, { id: "profile-bob", label: "Bob" }, ]); expect(result.sessions[0]?.createdActor).toEqual({ type: "human", id: "profile-ada", label: "Ada", + avatarUrl: "/api/users/profile-ada/avatar?v=42", }); expect(result.sessions[0]?.archivedBy).toEqual({ type: "human", @@ -76,6 +83,8 @@ it("preserves legacy list output across visibility, scope, creator, and search f id: profileId, displayName: profileId === "profile-ada" ? "Ada" : profileId === "profile-bob" ? "Bob" : "Carol", + hasAvatar: false, + updatedAt: now, })); const cfg = { agents: { diff --git a/src/gateway/session-utils-list.ts b/src/gateway/session-utils-list.ts index b1d966014b92..ccaaf4a8b83b 100644 --- a/src/gateway/session-utils-list.ts +++ b/src/gateway/session-utils-list.ts @@ -25,6 +25,7 @@ import { type SessionEntryPair, sortAndLimitSessionEntries } from "./session-lis import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; import { readSessionTitleFieldsFromTranscriptAsync as readScopedSessionTitleFieldsFromTranscriptAsync } from "./session-transcript-title-reader.js"; import type { + SessionActorProfileIdentity, SessionListRowContext, SessionListRowContextProvider, } from "./session-utils-contracts.js"; @@ -73,7 +74,7 @@ type ListSessionsFromStoreParams = { type SessionEntrySelection = { entries: SessionEntryPair[]; - creators: Array<{ id: string; label?: string }>; + creators: Array<{ id: string; label?: string; avatarUrl?: string }>; totalCount: number; limitApplied?: number; offset: number; @@ -82,25 +83,34 @@ type SessionEntrySelection = { }; function addSessionCreatorIdentity( - creators: Map, + creators: Map, entry: SessionEntry, - userProfileLabelById: Map, + userProfileIdentityById: Map, ): void { - const actor = projectSessionActor(entry.createdActor, userProfileLabelById); + const actor = projectSessionActor(entry.createdActor, userProfileIdentityById); const id = normalizeOptionalString(actor?.id); if (!id) { return; } const label = normalizeOptionalString(actor?.label); + const avatarUrl = normalizeOptionalString(actor?.avatarUrl); const existing = creators.get(id); - if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { - creators.set(id, { id, ...(label ? { label } : {}) }); + if ( + !existing || + (label && (!existing.label || label.localeCompare(existing.label) < 0)) || + (avatarUrl && !existing.avatarUrl) + ) { + creators.set(id, { + id, + ...(label ? { label } : existing?.label ? { label: existing.label } : {}), + ...(avatarUrl ? { avatarUrl } : existing?.avatarUrl ? { avatarUrl: existing.avatarUrl } : {}), + }); } } function sortSessionCreatorIdentities( - creators: Map, -): Array<{ id: string; label?: string }> { + creators: Map, +): Array<{ id: string; label?: string; avatarUrl?: string }> { return [...creators.values()].toSorted((a, b) => { const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id); return byLabel || a.id.localeCompare(b.id); @@ -165,7 +175,7 @@ function filterSessionEntries(params: { store: Record; opts: SessionsListParams; now: number; - userProfileLabelById?: Map; + userProfileIdentityById?: Map; getRowContext?: SessionListRowContextProvider; entryFilter?: (key: string, entry: SessionEntry) => boolean; }): Pick { @@ -184,7 +194,7 @@ function filterSessionEntries(params: { const creatorId = normalizeOptionalString(opts.creatorId); const activeCutoff = activeMinutes === undefined ? undefined : now - activeMinutes * 60_000; const entries: SessionEntryPair[] = []; - const creators = new Map(); + const creators = new Map(); for (const [key, entry] of Object.entries(store)) { if (params.entryFilter && !params.entryFilter(key, entry)) { @@ -282,8 +292,8 @@ function filterSessionEntries(params: { if (activeCutoff !== undefined && (entry.updatedAt ?? 0) < activeCutoff) { continue; } - if (params.userProfileLabelById) { - addSessionCreatorIdentity(creators, entry, params.userProfileLabelById); + if (params.userProfileIdentityById) { + addSessionCreatorIdentity(creators, entry, params.userProfileIdentityById); } if (creatorId && entry.createdActor?.id !== creatorId) { continue; @@ -310,7 +320,7 @@ function selectSessionEntries(params: { now: number; getRowContext?: SessionListRowContextProvider; defaultLimit?: number; - userProfileLabelById?: Map; + userProfileIdentityById?: Map; entryFilter?: (key: string, entry: SessionEntry) => boolean; }): SessionEntrySelection { const { creators, entries: filtered } = filterSessionEntries(params); @@ -336,10 +346,10 @@ function selectSessionEntries(params: { function prepareSessionList(params: ListSessionsFromStoreParams) { const { cfg, store, opts } = params; const now = Date.now(); - const userProfileLabelById = new Map(); + const userProfileIdentityById = new Map(); let rowContext: SessionListRowContext | undefined; const getRowContext = () => { - rowContext ??= buildSessionListRowContext({ store, now, userProfileLabelById }); + rowContext ??= buildSessionListRowContext({ store, now, userProfileIdentityById }); return rowContext; }; const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0; @@ -364,7 +374,7 @@ function prepareSessionList(params: ListSessionsFromStoreParams) { ? getRowContext : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, - userProfileLabelById, + userProfileIdentityById, }); const fullRowContext = rowContext || @@ -385,7 +395,7 @@ function prepareSessionList(params: ListSessionsFromStoreParams) { const sharedRowContext = fullRowContext ?? (selection.entries.length > 0 - ? buildSessionListRowMetadataContext({ now, userProfileLabelById }) + ? buildSessionListRowMetadataContext({ now, userProfileIdentityById }) : undefined); populateSessionListAcpMetadata({ cfg, diff --git a/src/gateway/session-utils-projection.ts b/src/gateway/session-utils-projection.ts index 3840156c5129..bc10f641965d 100644 --- a/src/gateway/session-utils-projection.ts +++ b/src/gateway/session-utils-projection.ts @@ -8,7 +8,10 @@ import { resolveStorePath, type SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { readRecentSessionUsageFromTranscript as readScopedRecentSessionUsageFromTranscript } from "./session-transcript-readers.js"; -import type { SessionListRowContext } from "./session-utils-contracts.js"; +import type { + SessionActorProfileIdentity, + SessionListRowContext, +} from "./session-utils-contracts.js"; import { buildStoreChildSessionIndex, getSingleRowChildSessionCandidates, @@ -22,20 +25,20 @@ import { resolveConcreteSessionStorePath } from "./session-utils-store.js"; export function buildSessionListRowContext(params: { store: Record; now: number; - userProfileLabelById?: Map; + userProfileIdentityById?: Map; }): SessionListRowContext { const subagentRuns = buildSubagentRunReadIndex(params.now); return buildSessionListRowContextFromParts({ subagentRuns, storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns), - userProfileLabelById: params.userProfileLabelById, + userProfileIdentityById: params.userProfileIdentityById, }); } function buildSessionListRowContextFromParts(params: { subagentRuns: ReturnType; storeChildSessionsByKey: Map; - userProfileLabelById?: Map; + userProfileIdentityById?: Map; }): SessionListRowContext { return { subagentRuns: params.subagentRuns, @@ -44,19 +47,19 @@ function buildSessionListRowContextFromParts(params: { thinkingMetadataByModelRef: new Map(), displayModelIdentityByKey: new Map(), modelCostConfigByModelRef: new Map(), - userProfileLabelById: params.userProfileLabelById ?? new Map(), + userProfileIdentityById: params.userProfileIdentityById ?? new Map(), acpSessionMetaByEntry: new Map(), }; } export function buildSessionListRowMetadataContext(params: { now: number; - userProfileLabelById?: Map; + userProfileIdentityById?: Map; }): SessionListRowContext { return buildSessionListRowContextFromParts({ subagentRuns: buildSubagentRunReadIndex(params.now), storeChildSessionsByKey: new Map(), - userProfileLabelById: params.userProfileLabelById, + userProfileIdentityById: params.userProfileIdentityById, }); } diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 4cf1dbbe0407..65ad745a5243 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -34,7 +34,10 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js" import { sessionHasAutomation } from "./session-automation-index.js"; import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; import { readSessionTitleFieldsFromTranscript as readScopedSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; -import type { SessionListRowContext } from "./session-utils-contracts.js"; +import type { + SessionActorProfileIdentity, + SessionListRowContext, +} from "./session-utils-contracts.js"; import { buildCompactionCheckpointPreview, deriveSessionTitle, @@ -58,11 +61,12 @@ import { } from "./session-utils-projection.js"; import { isGroupOrChannelDisplaySession, parseGroupKey } from "./session-utils-store.js"; import type { GatewaySessionRow } from "./session-utils.types.js"; +import { formatUserProfileAvatarPath } from "./user-profiles-http-path.js"; -/** Adds the current human profile label without persisting rename-prone display data. */ +/** Adds current durable human profile display data without persisting rename-prone metadata. */ export function projectSessionActor( actor: SessionEntry["createdActor"], - userProfileLabelById: Map = new Map(), + userProfileIdentityById: Map = new Map(), ): SessionCreatedActor | undefined { if (!actor) { return undefined; @@ -71,17 +75,26 @@ export function projectSessionActor( if (actor.type !== "human" || !id) { return { type: actor.type, ...(id ? { id } : {}) }; } - let label = userProfileLabelById.get(id); - if (!userProfileLabelById.has(id)) { + let identity = userProfileIdentityById.get(id); + if (!userProfileIdentityById.has(id)) { try { - label = normalizeOptionalString(getUserProfileListItem(id).displayName); + const profile = getUserProfileListItem(id); + const label = normalizeOptionalString(profile.displayName); + identity = { + ...(label ? { label } : {}), + ...(profile.hasAvatar + ? { + avatarUrl: `${formatUserProfileAvatarPath(profile.id)}?v=${profile.updatedAt}`, + } + : {}), + }; } catch { // Human actors can also be channel sender ids; only profile ids resolve here. - label = undefined; + identity = undefined; } - userProfileLabelById.set(id, label); + userProfileIdentityById.set(id, identity); } - return { type: actor.type, id, ...(label ? { label } : {}) }; + return { type: actor.type, id, ...identity }; } export function buildGatewaySessionRow(params: { @@ -410,7 +423,7 @@ export function buildGatewaySessionRow(params: { subagentRole: entry?.subagentRole, subagentControlScope: entry?.subagentControlScope, createdVia: entry?.createdVia, - createdActor: projectSessionActor(entry?.createdActor, rowContext?.userProfileLabelById), + createdActor: projectSessionActor(entry?.createdActor, rowContext?.userProfileIdentityById), createdAt: entry?.createdAt, forkSource: entry?.forkSource, previousSessionId: entry?.previousSessionId, @@ -430,7 +443,7 @@ export function buildGatewaySessionRow(params: { updatedAt, archived: entry?.archivedAt !== undefined, archivedAt: entry?.archivedAt, - archivedBy: projectSessionActor(entry?.archivedBy, rowContext?.userProfileLabelById), + archivedBy: projectSessionActor(entry?.archivedBy, rowContext?.userProfileIdentityById), pinned: entry?.pinnedAt !== undefined, pinnedAt: entry?.pinnedAt, icon: entry?.icon, diff --git a/src/gateway/session-viewer-presence.test.ts b/src/gateway/session-viewer-presence.test.ts new file mode 100644 index 000000000000..fa18a2e658bc --- /dev/null +++ b/src/gateway/session-viewer-presence.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionViewerPresenceDeclarations } from "./session-viewer-presence.js"; + +describe("session viewer presence declarations", () => { + it("replaces rather than accumulates connection session keys", () => { + const onReplace = vi.fn(); + const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + + expect(declarations.replace("conn-a", [" beta ", "alpha", "beta"])).toEqual(["alpha", "beta"]); + expect(declarations.replace("conn-a", ["gamma"])).toEqual(["gamma"]); + expect(onReplace.mock.calls).toEqual([ + ["conn-a", ["alpha", "beta"]], + ["conn-a", ["gamma"]], + ]); + }); + + it("publishes an empty declaration and forgets state on disconnect", () => { + const onReplace = vi.fn(); + const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + + declarations.replace("conn-a", ["alpha"]); + declarations.replace("conn-a", []); + declarations.replace("conn-a", ["beta"]); + declarations.unsubscribe("conn-a"); + declarations.replace("conn-a", ["beta"]); + + expect(onReplace.mock.calls).toEqual([ + ["conn-a", ["alpha"]], + ["conn-a", []], + ["conn-a", ["beta"]], + ["conn-a", ["beta"]], + ]); + }); + + it("does not republish an unchanged set", () => { + const onReplace = vi.fn(); + const declarations = createSessionViewerPresenceDeclarations({ onReplace }); + + declarations.replace("conn-a", ["beta", "alpha"]); + declarations.replace("conn-a", ["alpha", "beta"]); + + expect(onReplace).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/gateway/session-viewer-presence.ts b/src/gateway/session-viewer-presence.ts new file mode 100644 index 000000000000..b4f0a375018f --- /dev/null +++ b/src/gateway/session-viewer-presence.ts @@ -0,0 +1,70 @@ +// Per-connection viewer presence declarations. Message subscriptions are transport state, +// while this replace-set records only the sessions a client is actually rendering. + +type SessionViewerPresenceDeclarationsDeps = { + onReplace: (connId: string, sessionKeys: readonly string[]) => void; +}; + +type SessionViewerPresenceDeclarations = { + replace: (connId: string, sessionKeys: readonly string[]) => readonly string[]; + unsubscribe: (connId: string) => void; + stop: () => void; +}; + +function normalizedSessionKeys(sessionKeys: readonly string[]): string[] { + return [...new Set(sessionKeys.map((key) => key.trim()).filter(Boolean))].toSorted(); +} + +function sameKeys(left: readonly string[] | undefined, right: readonly string[]): boolean { + return ( + left !== undefined && + left.length === right.length && + left.every((key, index) => key === right[index]) + ); +} + +/** Owns one replace-set per websocket connection until empty declaration or disconnect. */ +export function createSessionViewerPresenceDeclarations( + deps: SessionViewerPresenceDeclarationsDeps, +): SessionViewerPresenceDeclarations { + const declarations = new Map(); + let stopped = false; + + const replace = (connId: string, sessionKeys: readonly string[]): readonly string[] => { + if (stopped) { + return []; + } + const normalizedConnId = connId.trim(); + if (!normalizedConnId) { + return []; + } + const next = normalizedSessionKeys(sessionKeys); + const previous = declarations.get(normalizedConnId); + if (sameKeys(previous, next) || (previous === undefined && next.length === 0)) { + return next; + } + if (next.length === 0) { + declarations.delete(normalizedConnId); + } else { + declarations.set(normalizedConnId, next); + } + deps.onReplace(normalizedConnId, next); + return next; + }; + + const unsubscribe = (connId: string) => { + const normalizedConnId = connId.trim(); + if (normalizedConnId) { + // The websocket close boundary publishes reason=disconnect and clears watchedSessions. + // Delete here first so a recycled connection id can never inherit an old declaration. + declarations.delete(normalizedConnId); + } + }; + + const stop = () => { + stopped = true; + declarations.clear(); + }; + + return { replace, unsubscribe, stop }; +} diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index de2f58d6e44a..0601f10b7fd4 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -65,7 +65,7 @@ export type SessionsListResultBase = { nextOffset?: number | null; hasMore?: boolean; /** Complete creator facet for the filtered result, independent of pagination. */ - creators?: Array<{ id: string; label?: string }>; + creators?: Array<{ id: string; label?: string; avatarUrl?: string }>; defaults: TDefaults; sessions: TRow[]; }; diff --git a/ui/src/app/user-profile.ts b/ui/src/app/user-profile.ts index 2155db9bb64f..646f89c51b11 100644 --- a/ui/src/app/user-profile.ts +++ b/ui/src/app/user-profile.ts @@ -2,12 +2,6 @@ import type { PresenceEntry } from "../api/types.ts"; export type AuthenticatedUser = NonNullable; export type PresencePayload = { presence: readonly PresenceEntry[] }; -export type ActorIdentityUser = { - id: string; - name?: string; - email?: string; - avatarUrl?: string; -}; export function readPresenceEntries(value: unknown): PresenceEntry[] | undefined { if (!value || typeof value !== "object") { @@ -48,52 +42,6 @@ export function resolveCurrentSelfUser({ : presenceUser; } -function normalizeActorIdentityUser( - user: AuthenticatedUser | null | undefined, -): ActorIdentityUser | null { - if (!user) { - return null; - } - const id = user.id.trim(); - if (!id) { - return null; - } - const optional = (value: string | null | undefined) => value?.trim() || undefined; - return { - id, - ...(optional(user.name) ? { name: optional(user.name) } : {}), - ...(optional(user.email) ? { email: optional(user.email) } : {}), - ...(optional(user.avatarUrl) ? { avatarUrl: optional(user.avatarUrl) } : {}), - }; -} - -/** Builds actor identities from the same self and presence sources as the sidebar footer. */ -export function resolveActorIdentityUsers({ - snapshotUser, - presenceEntries, - presenceInstanceId, -}: { - snapshotUser?: AuthenticatedUser | null; - presenceEntries?: readonly PresenceEntry[]; - presenceInstanceId?: string; -}): ReadonlyMap { - const users = new Map(); - const addUser = (user: AuthenticatedUser | null | undefined) => { - const normalized = normalizeActorIdentityUser(user); - if (!normalized) { - return; - } - users.set(normalized.id, { ...users.get(normalized.id), ...normalized }); - }; - for (const entry of presenceEntries ?? []) { - if (entry.reason !== "disconnect") { - addUser(entry.user); - } - } - addUser(resolveCurrentSelfUser({ snapshotUser, presenceEntries, presenceInstanceId })); - return users; -} - export function userProfileAvatarUrl( gatewayUrl: string, profileId: string, diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts index 468dbdd87374..96949407f461 100644 --- a/ui/src/components/app-sidebar-session-menu-renderers.ts +++ b/ui/src/components/app-sidebar-session-menu-renderers.ts @@ -1,7 +1,6 @@ import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; import { ref } from "lit/directives/ref.js"; -import type { ActorIdentityUser } from "../app/user-profile.ts"; import { t } from "../i18n/index.ts"; import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; @@ -97,7 +96,6 @@ export function renderSidebarCatalogViewMenu(params: { grouping: CatalogProjectGrouping; creators: readonly SessionCreatorOption[]; creatorFilterId: string | null; - resolveCreatorUser: (creatorId: string) => ActorIdentityUser | undefined; onGroupingChange: (grouping: CatalogProjectGrouping) => void; onCreatorFilterChange: (creatorId: string | null) => void; onClose: (restoreFocus: boolean) => void; @@ -194,12 +192,7 @@ export function renderSidebarCatalogViewMenu(params: { - ${renderSessionOwnerChip( - creator, - "row", - "created", - params.resolveCreatorUser(creator.id), - )} + ${renderSessionOwnerChip(creator, "row", "created")} ${creator.label ?? creator.id} `, @@ -221,7 +214,6 @@ export function renderSidebarSessionSortMenu(params: { showCron: boolean; creators: readonly SessionCreatorOption[]; creatorFilterId: string | null; - resolveCreatorUser: (creatorId: string) => ActorIdentityUser | undefined; onGroupingChange: (grouping: SidebarSessionsGrouping) => void; onSortModeChange: (mode: SidebarSessionSortMode) => void; onStatusFilterChange: (statusFilter: SidebarSessionStatusFilter) => void; @@ -372,12 +364,7 @@ export function renderSidebarSessionSortMenu(params: { - ${renderSessionOwnerChip( - creator, - "row", - "created", - params.resolveCreatorUser(creator.id), - )} + ${renderSessionOwnerChip(creator, "row", "created")} ${creator.label ?? creator.id} `, diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index f9582eff5dce..6fa89babd4e9 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -423,7 +423,7 @@ export function promoteSidebarSessionCreatedOrder( export function applySidebarSessionCreatorFilter(input: { projected: readonly SidebarRecentSession[]; creatorRows: readonly { createdActor?: SessionCreatedActor }[]; - creatorFacet: readonly { id: string; label?: string }[] | undefined; + creatorFacet: readonly { id: string; label?: string; avatarUrl?: string }[] | undefined; selectedCreatorId: string | null; }): { rows: SidebarRecentSession[]; diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index 50d4a554067e..7a46d7675651 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -4,6 +4,7 @@ import type { SessionObserverDigest } from "../../../packages/gateway-protocol/s import type { NavigationRouteId } from "../app-navigation.ts"; import { sessionHasPendingApproval } from "../app/approval-presentation.ts"; import type { ApplicationNavigationOptions } from "../app/context.ts"; +import type { AuthenticatedUser } from "../app/user-profile.ts"; import { t } from "../i18n/index.ts"; import { sessionHasBoard } from "../lib/board/provider.ts"; import { formatDurationCompact } from "../lib/format.ts"; @@ -26,10 +27,6 @@ import type { SessionDataController } from "./session-data-controller.ts"; import { renderSessionLeadingState } from "./session-leading-indicator.ts"; import type { SessionPullRequestIndicatorState } from "./session-menu-work.ts"; import type { SessionOrganizerController } from "./session-organizer-controller.ts"; -import { - resolveSessionOwnerUser, - type SessionOwnerIdentityHost, -} from "./session-owner-identity.ts"; import { renderSessionRowBadges } from "./session-row-badges.ts"; import { renderSidebarSessionSubtitle, @@ -40,7 +37,12 @@ import "./elapsed-time.ts"; const SIDEBAR_VISIBLE_CHILD_SESSION_LIMIT = 4; -export interface SessionListHost extends SessionOwnerIdentityHost { +export interface SessionListHost { + readonly sessionDataContext: + | { + gateway: { snapshot: { selfUser?: AuthenticatedUser | null } }; + } + | undefined; readonly sidebarLiveActivity: boolean; readonly sidebarNarrationLines: ReadonlyMap; readonly sidebarObserverDigests: ReadonlyMap; @@ -168,7 +170,6 @@ export function renderRecentSession(params: { pullRequestState, ownerActor, ownerAttribution, - resolveSessionOwnerUser(host, ownerActor?.id), ); const meta = display?.meta ?? session.meta; const rowMeta = session.pinned ? "" : meta; @@ -270,6 +271,7 @@ export function renderRecentSession(params: { : nothing} Boolean(value)) + .toSorted()[0]; + if (!existing || nextLabel !== existing.label || nextAvatarUrl !== existing.avatarUrl) { creators.set(id, { type: session.createdActor?.type ?? "human", id, - ...(label ? { label } : {}), + ...(nextLabel ? { label: nextLabel } : {}), + ...(nextAvatarUrl ? { avatarUrl: nextAvatarUrl } : {}), }); } } @@ -39,12 +47,10 @@ export function renderSessionOwnerChip( createdActor: SessionCreatedActor | null | undefined, size: "row" | "header", attribution: "created" | "archived" = "created", - user?: ActorIdentityUser, ) { return createdActor?.id ? html`` @@ -78,12 +84,12 @@ function ownerHue(id: string): number { * this chip is solid and never pulses/expires, in deliberate contrast to the * translucent, ring-styled live-presence chips. Render only when the gateway * has 2+ distinct creator identities (solo mode shows no attribution chrome). - * Actors absent from the current self/presence identities keep their stable - * initials because provenance outlives presence. + * Human actors use only the durable profile projection carried by the session + * record. Actors without that source keep stable initials because provenance + * outlives live connection presence. */ class SessionOwnerChip extends OpenClawLightDomElement { @property({ attribute: false }) createdActor: SessionCreatedActor | null = null; - @property({ attribute: false }) user: ActorIdentityUser | null = null; @property({ type: String }) size: "row" | "header" = "row"; @property({ type: String }) attribution: "created" | "archived" = "created"; @@ -101,13 +107,11 @@ class SessionOwnerChip extends OpenClawLightDomElement { this.attribution === "archived" ? "sessionsView.archivedBy" : "sessionsView.createdBy", { name: title }, ); - const user = this.user?.id.trim() === createdActor.id.trim() ? this.user : null; - const avatar = user + const avatar = createdActor.avatarUrl ? resolveAvatar({ - id: user.id, - name: user.name, - username: user.email, - profileAvatarUrl: user.avatarUrl, + id: createdActor.id, + name: createdActor.label, + profileAvatarUrl: createdActor.avatarUrl, }) : null; return html` @@ -117,9 +121,14 @@ class SessionOwnerChip extends OpenClawLightDomElement { role="img" aria-label=${accessibleLabel} title=${accessibleLabel} - >${avatar?.kind === "profile" && user + >${avatar?.kind === "profile" ? html`` diff --git a/ui/src/components/session-owner-identity.ts b/ui/src/components/session-owner-identity.ts deleted file mode 100644 index 17f8de1a8d8a..000000000000 --- a/ui/src/components/session-owner-identity.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - readPresenceEntries, - resolveActorIdentityUsers, - type ActorIdentityUser, - type AuthenticatedUser, -} from "../app/user-profile.ts"; - -export type SessionOwnerIdentityHost = object & { - readonly sessionDataContext: - | { - gateway: { snapshot: { selfUser?: AuthenticatedUser | null } }; - } - | undefined; - readonly sessionData: { - presencePayload?: unknown; - presenceInstanceId?: string; - }; -}; - -type SessionOwnerIdentityCache = { - snapshotUser: AuthenticatedUser | null | undefined; - presencePayload: unknown; - presenceInstanceId: string | undefined; - users: ReadonlyMap; -}; - -const cacheByHost = new WeakMap(); - -export function resolveSessionOwnerUser( - host: SessionOwnerIdentityHost, - actorId: string | null | undefined, -): ActorIdentityUser | undefined { - const id = actorId?.trim(); - if (!id) { - return undefined; - } - const snapshotUser = host.sessionDataContext?.gateway.snapshot.selfUser; - const { presencePayload, presenceInstanceId } = host.sessionData; - let cached = cacheByHost.get(host); - if ( - !cached || - cached.snapshotUser !== snapshotUser || - cached.presencePayload !== presencePayload || - cached.presenceInstanceId !== presenceInstanceId - ) { - cached = { - snapshotUser, - presencePayload, - presenceInstanceId, - users: resolveActorIdentityUsers({ - snapshotUser, - presenceEntries: readPresenceEntries(presencePayload), - presenceInstanceId, - }), - }; - cacheByHost.set(host, cached); - } - return cached.users.get(id); -} diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 60e50fcf71b7..1225cf1939a3 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -19,7 +19,6 @@ import { renderSidebarSessionSortMenu, } from "./app-sidebar-session-menu-renderers.ts"; import type { SessionMenuAction } from "./session-menu.ts"; -import { resolveSessionOwnerUser } from "./session-owner-identity.ts"; import type { SidebarMenusController, SidebarMenusControllerHost, @@ -293,7 +292,6 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen showCron: host.sessionsShowCron, creators: host.sessionOwnershipVisible ? host.sessionCreatorOptions : [], creatorFilterId: host.sessionCreatorFilterActive ? host.sessionCreatorFilterId : null, - resolveCreatorUser: (creatorId) => resolveSessionOwnerUser(host, creatorId), onGroupingChange: (grouping) => { host.sessionOrganizer.setSessionsGrouping(grouping); controller.closeSessionSortMenu({ restoreFocus: true }); @@ -333,7 +331,6 @@ export function renderSidebarCatalogViewMenuForController(controller: SidebarMen grouping: host.catalogProjectGrouping, creators: host.sessionOwnershipVisible ? host.sessionCreatorOptions : [], creatorFilterId: host.sessionCreatorFilterActive ? host.sessionCreatorFilterId : null, - resolveCreatorUser: (creatorId) => resolveSessionOwnerUser(host, creatorId), onGroupingChange: (grouping) => { host.setCatalogProjectGrouping(grouping); controller.closeCatalogViewMenu({ restoreFocus: true }); diff --git a/ui/src/components/viewer-facepile.test.ts b/ui/src/components/viewer-facepile.test.ts index 9c186037cb2c..354e94f0478a 100644 --- a/ui/src/components/viewer-facepile.test.ts +++ b/ui/src/components/viewer-facepile.test.ts @@ -150,7 +150,9 @@ it("shares an authenticated avatar blob between the same user in the roster and type ViewerFacepileElement = HTMLElement & { presencePayload: unknown; + selfUserId?: string; selfInstanceId?: string; + sessionKey?: string; variant: "session" | "footer"; buildInfo: ControlUiBuildInfo; gatewayVersion: string | null; @@ -170,6 +172,7 @@ const BUILD_INFO: ControlUiBuildInfo = { function mountFooterFacepile() { const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement; facepile.variant = "footer"; + facepile.selfUserId = "z-self"; facepile.selfInstanceId = "self-instance"; facepile.buildInfo = BUILD_INFO; facepile.gatewayVersion = "2026.7.1"; @@ -196,7 +199,7 @@ function mountFooterFacepile() { return facepile; } -it("shows one footer hover card with every online user and server details", async () => { +it("shows one footer hover card with other online users and server details", async () => { const facepile = mountFooterFacepile(); await vi.waitFor(async () => { @@ -215,20 +218,25 @@ it("shows one footer hover card with every online user and server details", asyn tooltip?.shadowRoot?.querySelector("wa-tooltip")?.open, ).toBe(true); const card = facepile.querySelector('.sidebar-presence-hover-card[slot="content"]'); - expect(card?.querySelector(".sidebar-hover-card__heading")?.textContent).toContain("Online · 3"); + expect( + [...facepile.querySelectorAll(".viewer-facepile [data-viewer-id]")].map((avatar) => + avatar.getAttribute("data-viewer-id"), + ), + ).toEqual(["alice", "bob"]); + expect(card?.querySelector(".sidebar-hover-card__heading")?.textContent).toContain("Online · 2"); const rows = [...(card?.querySelectorAll(".sidebar-hover-card__person") ?? [])]; expect(card?.querySelector(".sidebar-hover-card__people")?.getAttribute("tabindex")).toBe("0"); - expect(rows.map((row) => row.getAttribute("data-viewer-id"))).toEqual(["z-self", "alice", "bob"]); - expect(rows[0]?.querySelector(".sidebar-hover-card__you")?.textContent).toContain("you"); + expect(rows.map((row) => row.getAttribute("data-viewer-id"))).toEqual(["alice", "bob"]); + expect(card?.querySelector('[data-viewer-id="z-self"]')).toBeNull(); // Named users show the email as a subtitle; email-only users don't repeat it. - expect(rows[1]?.querySelector(".sidebar-hover-card__person-email")?.textContent).toBe( + expect(rows[0]?.querySelector(".sidebar-hover-card__person-email")?.textContent).toBe( "alice@example.test", ); - expect(rows[2]?.querySelector(".sidebar-hover-card__person-name")?.textContent?.trim()).toBe( + expect(rows[1]?.querySelector(".sidebar-hover-card__person-name")?.textContent?.trim()).toBe( "bob@example.test", ); - expect(rows[2]?.querySelector(".sidebar-hover-card__person-email")).toBeNull(); - expect(rows[1]?.querySelector("openclaw-viewer-avatar")).not.toBeNull(); + expect(rows[1]?.querySelector(".sidebar-hover-card__person-email")).toBeNull(); + expect(rows[0]?.querySelector("openclaw-viewer-avatar")).not.toBeNull(); expect(card?.textContent).toContain("Server"); expect(card?.querySelector(".sidebar-hover-card__summary")?.textContent).toContain( "v2026.7.2 · main · dirty", @@ -280,8 +288,59 @@ it("detects only other viewers watching the requested session", () => { }, ], }; - expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:active")).toBe(false); - expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:other")).toBe(true); + expect(hasSessionPresenceViewers(payload, "self", "self-instance", "agent:main:active")).toBe( + false, + ); + expect(hasSessionPresenceViewers(payload, "self", "self-instance", "agent:main:other")).toBe( + true, + ); +}); + +it.each([ + { + name: "the browser instance id is not populated yet", + selfInstanceId: undefined, + presence: [ + { + user: { id: "self", name: "Self" }, + watchedSessions: ["agent:main:active"], + }, + { + user: { id: "alice", name: "Alice" }, + watchedSessions: ["agent:main:active"], + }, + ], + }, + { + name: "the browser's own presence row lacks a user id", + selfInstanceId: "self-instance", + presence: [ + { instanceId: "self-instance", watchedSessions: ["agent:main:active"] }, + { + instanceId: "self-second-tab", + user: { id: "self", name: "Self" }, + watchedSessions: ["agent:main:active"], + }, + { + user: { id: "alice", name: "Alice" }, + watchedSessions: ["agent:main:active"], + }, + ], + }, +])("excludes authenticated self from session facepiles when $name", async (fixture) => { + const facepile = document.createElement("openclaw-viewer-facepile") as ViewerFacepileElement; + facepile.variant = "session"; + facepile.selfUserId = "self"; + facepile.selfInstanceId = fixture.selfInstanceId; + facepile.sessionKey = "agent:main:active"; + facepile.presencePayload = { presence: fixture.presence }; + document.body.append(facepile); + + await vi.waitFor(async () => { + await facepile.updateComplete; + expect(facepile.querySelector('[data-viewer-id="self"]')).toBeNull(); + expect(facepile.querySelector('[data-viewer-id="alice"]')).not.toBeNull(); + }); }); it("keeps collaboration UI dormant for a solo identity", () => { diff --git a/ui/src/components/viewer-facepile.ts b/ui/src/components/viewer-facepile.ts index 285637834cb2..6ef3bb4ef701 100644 --- a/ui/src/components/viewer-facepile.ts +++ b/ui/src/components/viewer-facepile.ts @@ -43,10 +43,11 @@ function readPresenceEntries(value: unknown): PresenceEntry[] { function projectPresenceViewers( entries: readonly PresenceEntry[], + authenticatedSelfUserId?: string, selfInstanceId?: string, ): { users: readonly PresenceViewer[]; selfUserId?: string } { const grouped = new Map(); - let selfUserId: string | undefined; + let selfUserId = normalized(authenticatedSelfUserId); for (const entry of entries) { if (entry.reason === "disconnect" || !entry.user?.id) { continue; @@ -58,7 +59,7 @@ function projectPresenceViewers( } else { grouped.set(userId, [entry]); } - if (selfInstanceId && entry.instanceId === selfInstanceId) { + if (!selfUserId && selfInstanceId && entry.instanceId === selfInstanceId) { selfUserId = userId; } } @@ -79,29 +80,41 @@ function projectPresenceViewers( } let cachedPresencePayload: unknown; +let cachedAuthenticatedSelfUserId: string | undefined; let cachedSelfInstanceId: string | undefined; let cachedPresenceProjection: ReturnType | undefined; -function projectPresencePayload(value: unknown, selfInstanceId?: string) { +function projectPresencePayload( + value: unknown, + authenticatedSelfUserId?: string, + selfInstanceId?: string, +) { if ( cachedPresenceProjection && cachedPresencePayload === value && + cachedAuthenticatedSelfUserId === authenticatedSelfUserId && cachedSelfInstanceId === selfInstanceId ) { return cachedPresenceProjection; } cachedPresencePayload = value; + cachedAuthenticatedSelfUserId = authenticatedSelfUserId; cachedSelfInstanceId = selfInstanceId; - cachedPresenceProjection = projectPresenceViewers(readPresenceEntries(value), selfInstanceId); + cachedPresenceProjection = projectPresenceViewers( + readPresenceEntries(value), + authenticatedSelfUserId, + selfInstanceId, + ); return cachedPresenceProjection; } export function hasSessionPresenceViewers( value: unknown, + authenticatedSelfUserId: string | undefined, selfInstanceId: string | undefined, sessionKey: string, ): boolean { - const projection = projectPresencePayload(value, selfInstanceId); + const projection = projectPresencePayload(value, authenticatedSelfUserId, selfInstanceId); return projection.users.some( (user) => user.id !== projection.selfUserId && user.watchedSessions.includes(sessionKey), ); @@ -155,7 +168,7 @@ class ViewerAvatar extends OpenClawLightDomContentsElement { } } -function renderPresenceCardRow(user: PresenceViewer, isSelf: boolean) { +function renderPresenceCardRow(user: PresenceViewer) { const label = presenceViewerLabel(user); // The email doubles as the label when no display name exists; repeating it // as a subtitle would just echo the same line. @@ -163,11 +176,7 @@ function renderPresenceCardRow(user: PresenceViewer, isSelf: boolean) { return html` diff --git a/ui/src/lib/session-viewer-presence.test.ts b/ui/src/lib/session-viewer-presence.test.ts new file mode 100644 index 000000000000..06279fd7ebcf --- /dev/null +++ b/ui/src/lib/session-viewer-presence.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient, GatewayHelloOk } from "../api/gateway.ts"; +import type { ApplicationGateway, ApplicationGatewaySnapshot } from "../app/gateway.ts"; +import { sessionViewerPresenceForGateway } from "./session-viewer-presence.ts"; + +const SESSION_VIEWERS_SET_METHOD = "sessions.viewers.set"; + +function createHello(mainSessionKey = "agent:main:main"): GatewayHelloOk { + return { + type: "hello-ok", + protocol: 1, + auth: { role: "operator", scopes: [] }, + features: { methods: [SESSION_VIEWERS_SET_METHOD] }, + snapshot: { sessionDefaults: { mainSessionKey } }, + } as GatewayHelloOk; +} + +function createGatewayHarness() { + const request = vi.fn().mockResolvedValue({ sessionKeys: [] }); + const client = { request } as unknown as GatewayBrowserClient; + let snapshot: ApplicationGatewaySnapshot = { + client, + phase: "connected", + offlineStable: false, + hello: createHello(), + canvasPluginSurfaceUrl: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + lastError: null, + lastErrorCode: null, + }; + const listeners = new Set<(value: ApplicationGatewaySnapshot) => void>(); + const unsubscribe = vi.fn(); + const gateway = { + get snapshot() { + return snapshot; + }, + connection: { gatewayUrl: "ws://example.test", token: "", bootstrapToken: "", password: "" }, + eventLog: [], + subscribe: vi.fn((listener: (value: ApplicationGatewaySnapshot) => void) => { + listeners.add(listener); + return () => { + unsubscribe(); + listeners.delete(listener); + }; + }), + subscribeEvents: () => () => {}, + subscribeEventLog: () => () => {}, + connect: vi.fn(), + setSessionKey: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } as ApplicationGateway; + return { + client, + gateway, + request, + unsubscribe, + setSnapshot(next: ApplicationGatewaySnapshot) { + snapshot = next; + for (const listener of listeners) { + listener(snapshot); + } + }, + }; +} + +async function flushSync() { + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); +}); + +describe("session viewer presence store", () => { + it("declares the bounded union and replaces it as panes switch or close", async () => { + const harness = createGatewayHarness(); + const store = sessionViewerPresenceForGateway(harness.gateway); + const firstPane = {}; + const secondPane = {}; + + store.watch(firstPane, ["main"]); + store.watch(secondPane, ["agent:main:other"]); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:main", "agent:main:other"], + }); + + store.watch(firstPane, ["agent:main:replacement"]); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:other", "agent:main:replacement"], + }); + + store.unwatch(secondPane); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:replacement"], + }); + + store.unwatch(firstPane); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: [], + }); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + }); + + it("declares empty while hidden and restores the set when visible", async () => { + const harness = createGatewayHarness(); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["agent:main:visible"]); + await flushSync(); + + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: [], + }); + + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:visible"], + }); + store.unwatch(owner); + await flushSync(); + }); + + it("redeclares aliases against the new client hello", async () => { + const harness = createGatewayHarness(); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["main"]); + await flushSync(); + expect(harness.request).toHaveBeenCalledTimes(1); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:main"], + }); + + harness.setSnapshot({ ...harness.gateway.snapshot, phase: "reconnecting", hello: null }); + const nextRequest = vi + .fn() + .mockResolvedValue({ sessionKeys: ["agent:main:visible"] }); + const nextClient = { request: nextRequest } as unknown as GatewayBrowserClient; + harness.setSnapshot({ + ...harness.gateway.snapshot, + client: nextClient, + phase: "connected", + hello: createHello("agent:work:home"), + }); + await flushSync(); + + expect(nextRequest).toHaveBeenCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:work:home"], + }); + store.unwatch(owner); + await flushSync(); + }); + + it("retries a transient declaration failure without stranding module listeners", async () => { + vi.useFakeTimers(); + const harness = createGatewayHarness(); + harness.request.mockRejectedValueOnce(new Error("temporarily unavailable")); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["agent:main:visible"]); + await flushSync(); + expect(harness.request).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(30_000); + await flushSync(); + expect(harness.request).toHaveBeenCalledTimes(2); + + store.unwatch(owner); + await flushSync(); + expect(vi.getTimerCount()).toBe(0); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + }); + + it("retries the final empty declaration before detaching listeners", async () => { + vi.useFakeTimers(); + const harness = createGatewayHarness(); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["agent:main:visible"]); + await flushSync(); + + let rejectClear!: (error: Error) => void; + harness.request.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectClear = reject; + }), + ); + store.unwatch(owner); + await flushSync(); + expect(harness.request).toHaveBeenCalledTimes(2); + expect(harness.unsubscribe).not.toHaveBeenCalled(); + + // Snapshot/visibility churn while the clear is in flight must not treat the + // sent signature as acknowledged and detach before a rejection can retry. + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + expect(harness.unsubscribe).not.toHaveBeenCalled(); + rejectClear(new Error("clear temporarily unavailable")); + await flushSync(); + + await vi.advanceTimersByTimeAsync(30_000); + await flushSync(); + expect(harness.request).toHaveBeenCalledTimes(3); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: [], + }); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not treat an older empty acknowledgement as the current final clear", async () => { + vi.useFakeTimers(); + const harness = createGatewayHarness(); + const store = sessionViewerPresenceForGateway(harness.gateway); + const owner = {}; + store.watch(owner, ["agent:main:visible"]); + await flushSync(); + + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + + let resolveVisible!: (value: { sessionKeys: string[] }) => void; + let rejectFinalClear!: (error: Error) => void; + harness.request + .mockReturnValueOnce( + new Promise((resolve) => { + resolveVisible = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectFinalClear = reject; + }), + ); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + store.unwatch(owner); + await flushSync(); + expect(harness.request).toHaveBeenCalledTimes(4); + + document.dispatchEvent(new Event("visibilitychange")); + await flushSync(); + expect(harness.unsubscribe).not.toHaveBeenCalled(); + + resolveVisible({ sessionKeys: ["agent:main:visible"] }); + rejectFinalClear(new Error("final clear temporarily unavailable")); + await flushSync(); + await vi.advanceTimersByTimeAsync(30_000); + await flushSync(); + + expect(harness.request).toHaveBeenCalledTimes(5); + expect(harness.request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: [], + }); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/lib/session-viewer-presence.ts b/ui/src/lib/session-viewer-presence.ts new file mode 100644 index 000000000000..87425e628a5f --- /dev/null +++ b/ui/src/lib/session-viewer-presence.ts @@ -0,0 +1,234 @@ +import { SESSION_VIEWER_PRESENCE_MAX_KEYS } from "../../../packages/gateway-protocol/src/schema/sessions-viewer-presence.js"; +import type { ApplicationGateway } from "../app/gateway.ts"; +import { isGatewayMethodAdvertised } from "./gateway-methods.ts"; +import { resolveSessionKey } from "./sessions/index.ts"; + +const SESSION_VIEWERS_SET_METHOD = "sessions.viewers.set"; + +const RETRY_BASE_MS = 30_000; +const RETRY_MAX_MS = 5 * 60_000; + +type SessionViewerPresenceStore = { + watch: (owner: object, sessionKeys: readonly string[]) => void; + unwatch: (owner: object) => void; +}; + +const stores = new WeakMap(); + +function createStore(gateway: ApplicationGateway): SessionViewerPresenceStore { + const watchedByOwner = new Map>(); + let knownClient = gateway.snapshot.client; + let lastHello: object | null = null; + let lastSignature: string | null = null; + let acknowledgedSignature: string | null = null; + let acknowledgedGeneration = 0; + let retryTimer: ReturnType | null = null; + let retryDelayMs = RETRY_BASE_MS; + let requestGeneration = 0; + let syncScheduled = false; + let scheduleGeneration = 0; + let attached = false; + let stopGatewaySnapshots: (() => void) | null = null; + let visibilityDocument: Document | null = null; + + const isActive = () => watchedByOwner.size > 0; + + const clearRetry = (resetDelay: boolean) => { + if (retryTimer !== null) { + globalThis.clearTimeout(retryTimer); + retryTimer = null; + } + if (resetDelay) { + retryDelayMs = RETRY_BASE_MS; + } + }; + + const visibleSessionKeys = (): string[] => { + const hello = gateway.snapshot.hello; + const keys = new Set(); + for (const watched of watchedByOwner.values()) { + for (const rawKey of watched) { + const key = resolveSessionKey(rawKey, hello).trim(); + if (key) { + keys.add(key); + } + } + } + return [...keys].toSorted().slice(0, SESSION_VIEWER_PRESENCE_MAX_KEYS); + }; + + const handleGatewaySnapshot = () => scheduleSync(); + const handleVisibilityChange = () => { + clearRetry(true); + scheduleSync(); + }; + + const attach = () => { + if (attached) { + return; + } + attached = true; + knownClient = gateway.snapshot.client; + lastHello = null; + lastSignature = null; + acknowledgedSignature = null; + acknowledgedGeneration = 0; + stopGatewaySnapshots = gateway.subscribe(handleGatewaySnapshot); + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", handleVisibilityChange); + visibilityDocument = document; + } + }; + + const detach = () => { + if (!attached) { + return; + } + attached = false; + stopGatewaySnapshots?.(); + stopGatewaySnapshots = null; + visibilityDocument?.removeEventListener("visibilitychange", handleVisibilityChange); + visibilityDocument = null; + clearRetry(true); + requestGeneration += 1; + scheduleGeneration += 1; + syncScheduled = false; + lastHello = null; + lastSignature = null; + acknowledgedSignature = null; + acknowledgedGeneration = 0; + }; + + function sync() { + syncScheduled = false; + if (!attached) { + return; + } + const snapshot = gateway.snapshot; + const client = snapshot.client; + if (client !== knownClient) { + clearRetry(true); + knownClient = client; + lastHello = null; + lastSignature = null; + acknowledgedSignature = null; + acknowledgedGeneration = 0; + } + const available = + snapshot.phase === "connected" && + client !== null && + snapshot.hello !== null && + isGatewayMethodAdvertised(snapshot, SESSION_VIEWERS_SET_METHOD) === true; + if (!available) { + lastHello = null; + lastSignature = null; + acknowledgedSignature = null; + acknowledgedGeneration = 0; + if (!isActive()) { + detach(); + } + return; + } + const sessionKeys = + typeof document !== "undefined" && document.visibilityState === "hidden" + ? [] + : visibleSessionKeys(); + const signature = JSON.stringify(sessionKeys); + if (snapshot.hello === lastHello && signature === lastSignature) { + if ( + !isActive() && + acknowledgedSignature === signature && + acknowledgedGeneration === requestGeneration + ) { + detach(); + } + return; + } + lastHello = snapshot.hello; + lastSignature = signature; + const currentGeneration = ++requestGeneration; + const isCurrentRequest = () => + attached && + currentGeneration === requestGeneration && + snapshot.hello === lastHello && + signature === lastSignature; + clearRetry(false); + const request = client.request(SESSION_VIEWERS_SET_METHOD, { sessionKeys }); + void request + .then(() => { + if (isCurrentRequest()) { + acknowledgedSignature = signature; + acknowledgedGeneration = currentGeneration; + retryDelayMs = RETRY_BASE_MS; + if (!isActive()) { + detach(); + } + } + }) + .catch(() => { + if (!isCurrentRequest()) { + return; + } + lastSignature = null; + const delayMs = retryDelayMs; + retryDelayMs = Math.min(retryDelayMs * 2, RETRY_MAX_MS); + retryTimer = globalThis.setTimeout(() => { + retryTimer = null; + if (attached) { + scheduleSync(); + } + }, delayMs); + }); + } + + function scheduleSync() { + if (!attached || syncScheduled) { + return; + } + syncScheduled = true; + const generation = scheduleGeneration; + globalThis.queueMicrotask(() => { + if (generation === scheduleGeneration) { + sync(); + } + }); + } + + const watch = (owner: object, sessionKeys: readonly string[]) => { + const next = new Set(sessionKeys.map((key) => key.trim()).filter(Boolean)); + const current = watchedByOwner.get(owner); + const unchanged = + current === undefined + ? next.size === 0 + : current.size === next.size && [...next].every((key) => current.has(key)); + if (unchanged) { + return; + } + if (next.size === 0) { + watchedByOwner.delete(owner); + } else { + watchedByOwner.set(owner, next); + } + clearRetry(true); + if (isActive()) { + attach(); + scheduleSync(); + } else if (attached) { + sync(); + } + }; + + return { watch, unwatch: (owner) => watch(owner, []) }; +} + +export function sessionViewerPresenceForGateway( + gateway: ApplicationGateway, +): SessionViewerPresenceStore { + const existing = stores.get(gateway); + if (existing) { + return existing; + } + const store = createStore(gateway); + stores.set(gateway, store); + return store; +} diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index d34faa358f7b..ce45c0be3f3c 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -14,7 +14,9 @@ vi.mock("../../app/native-gateways.runtime.ts", () => ({ nativeGatewaysCapability: () => nativeGateways.current, })); +import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts"; import type { ApplicationContext } from "../../app/context.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/gateway.ts"; import type { NativeGatewaysCapability, NativeGatewaysSnapshot, @@ -33,6 +35,7 @@ import { ChatPage } from "./chat-page.ts"; import { loadChatRoute } from "./route-loader.ts"; const WORK_SESSION_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef"; +const SESSION_VIEWERS_SET_METHOD = "sessions.viewers.set"; const CATALOG_KEY = { catalogId: "claude", hostId: "gateway:local", @@ -149,6 +152,46 @@ function setNavigationContext(page: ChatPage) { return { context, navigate, replace, setAgent, patch }; } +function setViewerPresenceContext(page: ChatPage) { + const navigation = setNavigationContext(page); + const request = vi.fn().mockResolvedValue({ sessionKeys: [] }); + const client = { request } as unknown as GatewayBrowserClient; + const hello = { + type: "hello-ok", + protocol: 1, + auth: { role: "operator", scopes: [] }, + features: { methods: [SESSION_VIEWERS_SET_METHOD] }, + snapshot: { sessionDefaults: { mainSessionKey: "agent:main:main" } }, + } as GatewayHelloOk; + const snapshotListeners = new Set<(snapshot: ApplicationGatewaySnapshot) => void>(); + (navigation.context as unknown as { gateway: ApplicationContext["gateway"] }).gateway = { + snapshot: { + client, + phase: "connected", + offlineStable: false, + hello, + canvasPluginSurfaceUrl: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + lastError: null, + lastErrorCode: null, + }, + connection: { gatewayUrl: "ws://example.test", token: "", bootstrapToken: "", password: "" }, + eventLog: [], + connect: vi.fn(), + setSessionKey: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + subscribe: (listener) => { + snapshotListeners.add(listener); + return () => snapshotListeners.delete(listener); + }, + subscribeEventLog: () => () => {}, + subscribeEvents: () => () => {}, + }; + return { ...navigation, request }; +} + function stubMatchMedia(matches: boolean) { vi.stubGlobal( "matchMedia", @@ -615,6 +658,65 @@ describe("chat page split layout host", () => { expect(panes[0]?.chatMessagesBySession).toBe(panes[1]?.chatMessagesBySession); }); + it("declares split panes, session switches, pane closes, and page disposal", async () => { + const page = new ChatPage(); + const { request } = setViewerPresenceContext(page); + page.data = { sessionKey: "main" }; + document.body.append(page); + setLayout(page, { + columns: [ + { + id: "c1", + panes: [{ id: "p1", sessionKey: "main" }], + paneWeights: [1], + }, + { + id: "c2", + panes: [{ id: "p2", sessionKey: "agent:main:other" }], + paneWeights: [1], + }, + ], + columnWeights: [0.5, 0.5], + activePaneId: "p2", + }); + await page.updateComplete; + await Promise.resolve(); + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:main", "agent:main:other"], + }); + + const otherPane = [...page.querySelectorAll("openclaw-chat-pane")].find( + (pane) => pane.paneId === "p2", + ); + otherPane?.onClosePane?.("p2"); + await page.updateComplete; + await Promise.resolve(); + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:main"], + }); + + page.data = { sessionKey: "agent:main:replacement" }; + await page.updateComplete; + await Promise.resolve(); + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:replacement"], + }); + + page.requestUpdate(); + page.remove(); + await page.updateComplete; + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { sessionKeys: [] }); + + document.body.append(page); + await Promise.resolve(); + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { + sessionKeys: ["agent:main:replacement"], + }); + page.remove(); + await Promise.resolve(); + expect(request).toHaveBeenLastCalledWith(SESSION_VIEWERS_SET_METHOD, { sessionKeys: [] }); + }); + it("renders only the active pane from a preserved split on narrow viewports", async () => { stubMatchMedia(true); const page = new ChatPage(); diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 2d34911ae1db..c6f5f6c8bf4a 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -20,6 +20,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { persistSessionBoardFace } from "./chat-board-face-persistence.ts"; import { stillOwnsCanonicalLocation } from "./chat-canonical-location.ts"; +import { ChatViewerPresenceController } from "./chat-viewer-presence.ts"; import "../../styles/chat.css"; import "./chat-pane.ts"; import { locationWithoutDraft, type SessionChatRouteData } from "./route-loader.ts"; @@ -43,6 +44,7 @@ import { singlePaneLayout, splitRatio, splitWeight, + visiblePanesOf, type ChatSplitLayout, type ChatSplitPane, } from "./split-layout.ts"; @@ -79,6 +81,7 @@ export class ChatPage extends OpenClawLightDomElement { private classicColumnId = "c1"; private classicPaneId = "p1"; private readonly mcpAppUnmountGate = new McpAppUnmountGate(this); + private readonly viewerPresence = new ChatViewerPresenceController(this); override connectedCallback() { super.connectedCallback(); @@ -97,9 +100,12 @@ export class ChatPage extends OpenClawLightDomElement { window.addEventListener(UI_COMMAND_EVENT, this.handleUiCommand); this.syncRouteAgent(); this.syncRouteToActivePane(); + const layout = this.layout ?? this.classicLayout(); + this.viewerPresence.sync(this.context?.gateway, layout, this.narrow); } override disconnectedCallback() { + this.viewerPresence.dispose(); this.subscriptions.clear(); this.mediaQuery?.removeEventListener("change", this.handleViewportChange); this.mediaQuery = null; @@ -116,6 +122,10 @@ export class ChatPage extends OpenClawLightDomElement { } override updated(changedProperties: Map) { + const layout = this.layout ?? this.classicLayout(); + if (this.isConnected) { + this.viewerPresence.sync(this.context?.gateway, layout, this.narrow); + } const data = this.data; const activePane = this.layout ? findPane(this.layout, this.layout.activePaneId)?.pane : null; const routeDraftWasRendered = @@ -695,14 +705,12 @@ export class ChatPage extends OpenClawLightDomElement { override render() { const indicator = this.dropIndicator; const layout = this.layout ?? this.classicLayout(); - const activeLocation = findPane(layout, layout.activePaneId); - const renderedPaneOwners = this.narrow - ? activeLocation - ? [{ columnId: activeLocation.column.id, pane: activeLocation.pane }] - : [] - : layout.columns.flatMap((column) => - column.panes.map((pane) => ({ columnId: column.id, pane })), - ); + const renderedPaneIds = new Set(visiblePanesOf(layout, this.narrow).map((pane) => pane.id)); + const renderedPaneOwners = layout.columns.flatMap((column) => + column.panes + .filter((pane) => renderedPaneIds.has(pane.id)) + .map((pane) => ({ columnId: column.id, pane })), + ); const nextPaneKeys = new Set( renderedPaneOwners.map(({ columnId, pane }) => JSON.stringify([columnId, pane.id, pane.sessionKey]), diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 3524d381373c..83a0b3e1a18a 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -10,7 +10,6 @@ import type { import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; import { hasOperatorWriteAccess, hasOperatorAdminAccess } from "../../app/operator-access.ts"; -import { readPresenceEntries, resolveActorIdentityUsers } from "../../app/user-profile.ts"; import { icons } from "../../components/icons.ts"; import { listSessionCreators } from "../../components/session-owner-chip.ts"; import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts"; @@ -100,14 +99,6 @@ export abstract class ChatPaneHeader extends ChatPaneContext { : branchSwitchWorking ? t("chat.sessionHeader.branchSwitchUnavailable") : null; - const ownerActorId = row?.createdActor?.id?.trim(); - const ownerUser = ownerActorId - ? resolveActorIdentityUsers({ - snapshotUser: this.context.gateway.snapshot.selfUser, - presenceEntries: readPresenceEntries(this.presencePayload), - presenceInstanceId: this.context.gateway.snapshot.client?.instanceId, - }).get(ownerActorId) - : undefined; return renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, @@ -120,7 +111,6 @@ export abstract class ChatPaneHeader extends ChatPaneContext { this.state?.sessionsResult?.creators ?? listSessionCreators(this.state?.sessionsResult?.sessions ?? []) ).length >= 2, - ownerUser, catalog, editing: this.headerEditing && this.headerRenameSessionKey === row?.key, renameValue: this.headerRenameValue, @@ -147,12 +137,14 @@ export abstract class ChatPaneHeader extends ChatPaneContext { !catalog && hasSessionPresenceViewers( this.presencePayload, + this.context.gateway.snapshot.selfUser?.id, this.context.gateway.snapshot.client?.instanceId, this.state?.sessionKey ?? "", ) ? html` pane.sessionKey), + ); + } + } + + dispose() { + if (this.gateway) { + sessionViewerPresenceForGateway(this.gateway).unwatch(this.owner); + this.gateway = null; + } + } +} diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 2169780e0353..423345b5f40b 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -273,15 +273,17 @@ describe("chat pane header", () => { expect(dormant.container.querySelector("openclaw-session-owner-chip")).toBeNull(); }); - it("renders a resolved owner avatar with the header attribution semantics", async () => { + it("renders the durable session actor avatar with the header attribution semantics", async () => { const mounted = mount({ showOwnerChip: true, - session: row({ createdActor: { type: "human", id: "profile-ada", label: "Ada" } }), - ownerUser: { - id: "profile-ada", - name: "Ada", - avatarUrl: "/api/users/profile-ada/avatar", - }, + session: row({ + createdActor: { + type: "human", + id: "profile-ada", + label: "Ada", + avatarUrl: "/api/users/profile-ada/avatar?v=7", + }, + }), }); await vi.waitFor(() => { diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 38fc4682eb5d..2fa2f28ad010 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -8,7 +8,6 @@ import type { } from "../../../app/native-gateways.runtime.ts"; import { isNativeWebChromeHost } from "../../../app/native-web-chrome.ts"; import { beginNativeWindowDrag } from "../../../app/native-window-drag.ts"; -import type { ActorIdentityUser } from "../../../app/user-profile.ts"; import { COMMAND_PALETTE_OPEN_EVENT, SHELL_NAV_DRAWER_TOGGLE_EVENT, @@ -33,7 +32,6 @@ type ChatPaneHeaderProps = { title: string; session: GatewaySessionRow | undefined; showOwnerChip?: boolean; - ownerUser?: ActorIdentityUser; catalog: boolean; editing: boolean; renameValue: string; @@ -313,7 +311,6 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { props.showOwnerChip ? props.session?.createdActor : undefined, "header", "created", - props.ownerUser, )} ${!props.catalog && props.workspaceLabel ? html` diff --git a/ui/src/pages/chat/split-layout.ts b/ui/src/pages/chat/split-layout.ts index 48272cd780e3..408b0eb0db5f 100644 --- a/ui/src/pages/chat/split-layout.ts +++ b/ui/src/pages/chat/split-layout.ts @@ -106,6 +106,15 @@ export function panesOf(layout: ChatSplitLayout): ChatSplitPane[] { return layout.columns.flatMap((column) => column.panes.map((pane) => ({ ...pane }))); } +/** Panes actually rendered at the current viewport width. */ +export function visiblePanesOf(layout: ChatSplitLayout, narrow: boolean): ChatSplitPane[] { + if (!narrow) { + return panesOf(layout); + } + const activePane = findPane(layout, layout.activePaneId)?.pane; + return activePane ? [activePane] : []; +} + export function insertPane( layout: ChatSplitLayout, targetPaneId: string, diff --git a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts index ef617aa6752e..0f6ca7f351c4 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts @@ -36,7 +36,7 @@ async function selectCreator(sidebar: SidebarLifecycleState, creatorId: string | } describe("AppSidebar session ownership", () => { - it("renders self and presence avatars while unmatched actors keep initials", async () => { + it("renders durable actor avatars identically regardless of live presence", async () => { const gateway = createGatewayHarness({} as GatewayBrowserClient); gateway.publish({ selfUser: { @@ -61,8 +61,18 @@ describe("AppSidebar session ownership", () => { if (!ada || !bob || !carol) { throw new Error("expected creator rows"); } - ada.createdActor = { type: "human", id: "profile-ada", label: "Ada" }; - bob.createdActor = { type: "human", id: "profile-bob", label: "Bob" }; + ada.createdActor = { + type: "human", + id: "profile-ada", + label: "Ada", + avatarUrl: "/api/users/profile-ada/avatar?v=1", + }; + bob.createdActor = { + type: "human", + id: "profile-bob", + label: "Bob", + avatarUrl: "/api/users/profile-bob/avatar?v=2", + }; carol.createdActor = { type: "human", id: "profile-carol", label: "Carol" }; result.creators = [ { id: "profile-ada", label: "Ada" }, @@ -71,18 +81,6 @@ describe("AppSidebar session ownership", () => { ]; const { sidebar } = await mountSidebar(gateway.gateway, harness.sessions); - gateway.publishEvent("presence", { - presence: [ - { - instanceId: "bob-browser", - user: { - id: "profile-bob", - name: "Bob", - avatarUrl: "/api/users/profile-bob/avatar?v=2", - }, - }, - ], - }); harness.publishList({ result, agentId: "main" }); await waitForFast(() => { @@ -93,6 +91,28 @@ describe("AppSidebar session ownership", () => { sidebar.querySelector('[data-session-key="agent:main:bob"] openclaw-viewer-avatar img'), ).not.toBeNull(); }); + const bobAvatarBefore = sidebar + .querySelector('[data-session-key="agent:main:bob"] openclaw-viewer-avatar img') + ?.getAttribute("src"); + + gateway.publishEvent("presence", { + presence: [ + { + instanceId: "bob-browser", + user: { + id: "profile-bob", + name: "Bob", + avatarUrl: "/api/users/profile-bob/avatar?v=99", + }, + }, + ], + }); + await sidebar.updateComplete; + expect( + sidebar + .querySelector('[data-session-key="agent:main:bob"] openclaw-viewer-avatar img') + ?.getAttribute("src"), + ).toBe(bobAvatarBefore); const adaChip = sidebar.querySelector( '[data-session-key="agent:main:ada"] .session-owner-chip',