mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 14:21:34 +00:00
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:
committed by
GitHub
parent
fa570d8ed3
commit
ff72f287c3
@@ -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"),
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
106
src/gateway/server-methods/sessions-viewers.test.ts
Normal file
106
src/gateway/server-methods/sessions-viewers.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
src/gateway/session-viewer-presence.test.ts
Normal file
44
src/gateway/session-viewer-presence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
70
src/gateway/session-viewer-presence.ts
Normal file
70
src/gateway/session-viewer-presence.ts
Normal 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 };
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
273
ui/src/lib/session-viewer-presence.test.ts
Normal file
273
ui/src/lib/session-viewer-presence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
234
ui/src/lib/session-viewer-presence.ts
Normal file
234
ui/src/lib/session-viewer-presence.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
31
ui/src/pages/chat/chat-viewer-presence.ts
Normal file
31
ui/src/pages/chat/chat-viewer-presence.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user