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.
This commit is contained in:
Peter Steinberger
2026-07-29 13:56:54 -04:00
committed by GitHub
parent fa570d8ed3
commit ff72f287c3
50 changed files with 1398 additions and 358 deletions

View File

@@ -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"),

View File

@@ -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?

View File

@@ -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";

View File

@@ -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,

View File

@@ -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",

View File

@@ -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. */

View File

@@ -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<typeof SessionsViewerPresenceSetParamsSchema>;
export type SessionsViewerPresenceSetResult = Static<typeof SessionsViewerPresenceSetResultSchema>;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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",

View File

@@ -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" },

View File

@@ -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<ReturnType<typeof prepareGatewayRuntimeState>>;
type GatewayLogger = ReturnType<typeof createSubsystemLogger>;
@@ -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<ReturnType<GatewayRequestContext["subscribeSessionMessageEvents"]>>;
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<string, RestartRecoveryCandidate>();
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();

View File

@@ -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<typeof createControlUiSessionPullRequestSubscriptions>;
sessionViewerPresence?: ReturnType<typeof createSessionViewerPresenceDeclarations>;
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,
};

View File

@@ -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");

View File

@@ -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",

View File

@@ -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(

View File

@@ -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<string, unknown>;
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" }),
);
});
});

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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<string, unknown>):
}
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<Record<string, unknown>>;
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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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();

View File

@@ -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<typeof buildSubagentRunReadIndex>;
storeChildSessionsByKey: Map<string, string[]>;
@@ -21,7 +26,7 @@ export type SessionListRowContext = {
>;
displayModelIdentityByKey: Map<string, { provider?: string; model?: string }>;
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
userProfileLabelById: Map<string, string | undefined>;
userProfileIdentityById: Map<string, SessionActorProfileIdentity | undefined>;
acpSessionMetaByEntry: Map<SessionEntry, SessionAcpMeta | undefined>;
};

View File

@@ -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: {

View File

@@ -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<string, { id: string; label?: string }>,
creators: Map<string, { id: string; label?: string; avatarUrl?: string }>,
entry: SessionEntry,
userProfileLabelById: Map<string, string | undefined>,
userProfileIdentityById: Map<string, SessionActorProfileIdentity | undefined>,
): 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<string, { id: string; label?: string }>,
): Array<{ id: string; label?: string }> {
creators: Map<string, { id: string; label?: string; avatarUrl?: string }>,
): 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<string, SessionEntry>;
opts: SessionsListParams;
now: number;
userProfileLabelById?: Map<string, string | undefined>;
userProfileIdentityById?: Map<string, SessionActorProfileIdentity | undefined>;
getRowContext?: SessionListRowContextProvider;
entryFilter?: (key: string, entry: SessionEntry) => boolean;
}): Pick<SessionEntrySelection, "creators" | "entries"> {
@@ -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<string, { id: string; label?: string }>();
const creators = new Map<string, { id: string; label?: string; avatarUrl?: string }>();
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<string, string | undefined>;
userProfileIdentityById?: Map<string, SessionActorProfileIdentity | undefined>;
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<string, string | undefined>();
const userProfileIdentityById = new Map<string, SessionActorProfileIdentity | undefined>();
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,

View File

@@ -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<string, SessionEntry>;
now: number;
userProfileLabelById?: Map<string, string | undefined>;
userProfileIdentityById?: Map<string, SessionActorProfileIdentity | undefined>;
}): 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<typeof buildSubagentRunReadIndex>;
storeChildSessionsByKey: Map<string, string[]>;
userProfileLabelById?: Map<string, string | undefined>;
userProfileIdentityById?: Map<string, SessionActorProfileIdentity | undefined>;
}): 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<string, string | undefined>;
userProfileIdentityById?: Map<string, SessionActorProfileIdentity | undefined>;
}): SessionListRowContext {
return buildSessionListRowContextFromParts({
subagentRuns: buildSubagentRunReadIndex(params.now),
storeChildSessionsByKey: new Map(),
userProfileLabelById: params.userProfileLabelById,
userProfileIdentityById: params.userProfileIdentityById,
});
}

View File

@@ -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<string, string | undefined> = new Map(),
userProfileIdentityById: Map<string, SessionActorProfileIdentity | undefined> = 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,

View File

@@ -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();
});
});

View File

@@ -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<string, readonly string[]>();
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 };
}

View File

@@ -65,7 +65,7 @@ export type SessionsListResultBase<TDefaults, TRow> = {
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[];
};

View File

@@ -2,12 +2,6 @@ import type { PresenceEntry } from "../api/types.ts";
export type AuthenticatedUser = NonNullable<PresenceEntry["user"]>;
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<string, ActorIdentityUser> {
const users = new Map<string, ActorIdentityUser>();
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,

View File

@@ -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: {
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === creator.id ? icons.check : nothing}</span
>
${renderSessionOwnerChip(
creator,
"row",
"created",
params.resolveCreatorUser(creator.id),
)}
${renderSessionOwnerChip(creator, "row", "created")}
<span class="session-menu__text">${creator.label ?? creator.id}</span>
</wa-dropdown-item>
`,
@@ -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: {
<span slot="details" class="session-menu__check" aria-hidden="true"
>${params.creatorFilterId === creator.id ? icons.check : nothing}</span
>
${renderSessionOwnerChip(
creator,
"row",
"created",
params.resolveCreatorUser(creator.id),
)}
${renderSessionOwnerChip(creator, "row", "created")}
<span class="session-menu__text">${creator.label ?? creator.id}</span>
</wa-dropdown-item>
`,

View File

@@ -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[];

View File

@@ -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<string, string>;
readonly sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest>;
@@ -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}
<openclaw-viewer-facepile
.presencePayload=${host.sessionData.presencePayload}
.selfUserId=${host.sessionDataContext?.gateway.snapshot.selfUser?.id}
.selfInstanceId=${host.sessionData.presenceInstanceId}
.sessionKey=${session.key}
.maxVisible=${3}

View File

@@ -1,5 +1,4 @@
import { html, nothing } from "lit";
import type { ActorIdentityUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
@@ -42,7 +41,6 @@ export function renderSessionLeadingState(
pullRequestState: SessionPullRequestIndicatorState,
ownerActor: SessionCreatedActor | null | undefined,
attribution: "created" | "archived",
ownerUser?: ActorIdentityUser,
) {
const running = session.hasActiveRun || session.status === "running";
@@ -74,7 +72,7 @@ export function renderSessionLeadingState(
return {
running,
leadingIndicator: renderSessionGlyph({
content: renderSessionOwnerChip(ownerActor, "row", attribution, ownerUser),
content: renderSessionOwnerChip(ownerActor, "row", attribution),
running,
circular: true,
badge: renderGlyphBadge(session, pullRequestState),

View File

@@ -1,7 +1,6 @@
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { SessionCreatedActor as ProtocolSessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { ActorIdentityUser } from "../app/user-profile.ts";
import { t } from "../i18n/index.ts";
import { resolveAvatar } from "../lib/identity-avatar.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
@@ -20,12 +19,21 @@ export function listSessionCreators(
continue;
}
const label = session.createdActor?.label?.trim();
const avatarUrl = session.createdActor?.avatarUrl?.trim();
const existing = creators.get(id);
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
const nextLabel =
label && (!existing?.label || label.localeCompare(existing.label) < 0)
? label
: existing?.label;
const nextAvatarUrl = [existing?.avatarUrl, avatarUrl]
.filter((value): value is string => 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`<openclaw-session-owner-chip
.createdActor=${createdActor}
.user=${user ?? null}
size=${size}
attribution=${attribution}
></openclaw-session-owner-chip>`
@@ -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`<openclaw-viewer-avatar
.user=${{ ...user, watchedSessions: [] }}
.user=${{
id: createdActor.id,
name: createdActor.label,
avatarUrl: createdActor.avatarUrl,
watchedSessions: [],
}}
variant="session"
aria-hidden="true"
></openclaw-viewer-avatar>`

View File

@@ -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<string, ActorIdentityUser>;
};
const cacheByHost = new WeakMap<SessionOwnerIdentityHost, SessionOwnerIdentityCache>();
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);
}

View File

@@ -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 });

View File

@@ -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<HTMLElement & { open: boolean }>("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", () => {

View File

@@ -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<string, PresenceEntry[]>();
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<typeof projectPresenceViewers> | 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`<div class="sidebar-hover-card__person" data-viewer-id=${user.id}>
<openclaw-viewer-avatar .user=${user} variant="footer"></openclaw-viewer-avatar>
<span class="sidebar-hover-card__person-text">
<span class="sidebar-hover-card__person-name"
>${label}${isSelf
? html` <span class="sidebar-hover-card__you">(${t("presence.you")})</span>`
: nothing}</span
>
<span class="sidebar-hover-card__person-name">${label}</span>
${subtitle
? html`<span class="sidebar-hover-card__person-email">${subtitle}</span>`
: nothing}
@@ -177,6 +186,7 @@ function renderPresenceCardRow(user: PresenceViewer, isSelf: boolean) {
class ViewerFacepile extends OpenClawLightDomContentsElement {
@property({ attribute: false }) presencePayload: unknown;
@property({ attribute: false }) selfUserId?: string;
@property({ attribute: false }) selfInstanceId?: string;
@property({ attribute: false }) sessionKey?: string;
@property({ type: Number, attribute: "max-visible" }) maxVisible = 3;
@@ -185,7 +195,11 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
@property({ attribute: false }) gatewayVersion: string | null = null;
override render() {
const projection = projectPresencePayload(this.presencePayload, this.selfInstanceId);
const projection = projectPresencePayload(
this.presencePayload,
this.selfUserId,
this.selfInstanceId,
);
const sessionKey = this.sessionKey;
const users = sessionKey
? projection.users.filter(
@@ -193,7 +207,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
)
: this.variant === "footer"
? projection.users.filter((user) => user.id !== projection.selfUserId)
: projection.users;
: projection.users.filter((user) => user.id !== projection.selfUserId);
if (users.length === 0) {
return nothing;
}
@@ -232,10 +246,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
if (this.variant !== "footer") {
return facepile;
}
// Self anchors the hover card; everyone else keeps the projection order.
const roster = [...projection.users].toSorted((a, b) =>
a.id === projection.selfUserId ? -1 : b.id === projection.selfUserId ? 1 : 0,
);
const roster = projection.users.filter((user) => user.id !== projection.selfUserId);
return html`
<openclaw-tooltip class="sidebar-hover-tooltip">
<span
@@ -256,9 +267,7 @@ class ViewerFacepile extends OpenClawLightDomContentsElement {
tabindex="0"
aria-label=${`${t("presence.rosterTitle")} · ${roster.length}`}
>
${roster.map((user) =>
renderPresenceCardRow(user, user.id === projection.selfUserId),
)}
${roster.map((user) => renderPresenceCardRow(user))}
</div>
</section>
<div class="sidebar-hover-card__divider" role="separator"></div>

View File

@@ -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<GatewayBrowserClient["request"]>().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<GatewayBrowserClient["request"]>()
.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<never>((_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<never>((_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();
});
});

View File

@@ -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<ApplicationGateway, SessionViewerPresenceStore>();
function createStore(gateway: ApplicationGateway): SessionViewerPresenceStore {
const watchedByOwner = new Map<object, Set<string>>();
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<typeof globalThis.setTimeout> | 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<string>();
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;
}

View File

@@ -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<GatewayBrowserClient["request"]>().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<RenderedPane>("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();

View File

@@ -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<PropertyKey, unknown>) {
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]),

View File

@@ -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`<openclaw-viewer-facepile
class="chat-pane__presence"
.presencePayload=${this.presencePayload}
.selfUserId=${this.context.gateway.snapshot.selfUser?.id}
.selfInstanceId=${this.context.gateway.snapshot.client?.instanceId}
.sessionKey=${this.state?.sessionKey}
.maxVisible=${4}

View File

@@ -155,28 +155,29 @@ export class ChatPane extends ChatPaneHeader {
sessionKey: `${currentAgentId ?? ""}\0${state.sessionKey}`,
session: selectedSession,
});
const gatewaySnapshot = this.context.gateway.snapshot;
const multiIdentity = this.hasMultipleIdentities();
const suggestionViewer =
multiIdentity &&
!selectedSessionArchived &&
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) &&
selectedSession?.visibility === "suggest" &&
selectedSession.sharingRole === "viewer" &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.suggestions.add") ===
true &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.suggestions.list") === true;
isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.add") === true &&
isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.list") === true;
const disabledReason =
sessionParticipationBlocked && !suggestionViewer
? t("chat.sessionSharing.readOnlyNotice")
: null;
const typingEnabled =
multiIdentity &&
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) &&
!catalogKey &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.typing") === true &&
isGatewayMethodAdvertised(gatewaySnapshot, "session.typing") === true &&
hasSessionPresenceViewers(
this.presencePayload,
this.context.gateway.snapshot.client?.instanceId,
gatewaySnapshot.selfUser?.id,
gatewaySnapshot.client?.instanceId,
state.sessionKey,
);
// Never flash "view-only" while metadata loads; after loading, anything short
@@ -217,7 +218,6 @@ export class ChatPane extends ChatPaneHeader {
const chatMainWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const selectedSessionRailMode =
this.sessionRailModeSessionKey === state.sessionKey ? this.sessionRailMode : "hidden";
const gatewaySnapshot = this.context.gateway.snapshot;
const selfUser = resolveCurrentSelfUser({
snapshotUser: gatewaySnapshot.selfUser,
presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot),

View File

@@ -0,0 +1,31 @@
import type { ApplicationGateway } from "../../app/gateway.ts";
import { sessionViewerPresenceForGateway } from "../../lib/session-viewer-presence.ts";
import { visiblePanesOf, type ChatSplitLayout } from "./split-layout.ts";
/** Moves a mounted chat page's viewer declaration between gateway lifecycles. */
export class ChatViewerPresenceController {
private gateway: ApplicationGateway | null = null;
constructor(private readonly owner: object) {}
sync(gateway: ApplicationGateway | undefined, layout: ChatSplitLayout, narrow: boolean) {
const nextGateway = gateway && typeof gateway.subscribe === "function" ? gateway : null;
if (this.gateway && this.gateway !== nextGateway) {
sessionViewerPresenceForGateway(this.gateway).unwatch(this.owner);
}
this.gateway = nextGateway;
if (nextGateway) {
sessionViewerPresenceForGateway(nextGateway).watch(
this.owner,
visiblePanesOf(layout, narrow).map((pane) => pane.sessionKey),
);
}
}
dispose() {
if (this.gateway) {
sessionViewerPresenceForGateway(this.gateway).unwatch(this.owner);
this.gateway = null;
}
}
}

View File

@@ -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(() => {

View File

@@ -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`

View File

@@ -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,

View File

@@ -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',