diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 84784e059b3e..1d6f13b39959 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -1290,6 +1290,7 @@ class OpenClawShell extends OpenClawLightDomElement { schema: runtimeConfig.configSchema, value: runtimeConfig.configForm ?? runtimeConfig.configSnapshot?.config ?? null, uiHints: runtimeConfig.configUiHints, + identityAvailable: Boolean(gatewaySnapshot.selfUser), }); const onboarding = this.onboardingMode; const navDrawerOpen = this.navDrawerOpen && !onboarding; diff --git a/ui/src/app/gateway-store.test.ts b/ui/src/app/gateway-store.test.ts index bd97d9fbb2ce..73ec35dda104 100644 --- a/ui/src/app/gateway-store.test.ts +++ b/ui/src/app/gateway-store.test.ts @@ -18,8 +18,11 @@ const HELLO: GatewayHelloOk = { class FakeGatewayClient { started = 0; stopped = 0; + readonly instanceId: string; - constructor(readonly opts: GatewayBrowserClientOptions) {} + constructor(readonly opts: GatewayBrowserClientOptions) { + this.instanceId = opts.instanceId ?? ""; + } start() { this.started += 1; @@ -165,6 +168,88 @@ describe("createApplicationGateway reconnecting snapshot", () => { expect(gateway.snapshot.reconnecting).toBe(true); }); + it("projects only this browser connection's optional presence identity", () => { + const { gateway, current } = createStore(); + gateway.start(); + const instanceId = current().opts.instanceId; + current().opts.onHello?.({ + ...HELLO, + snapshot: { + presence: [ + { instanceId: "someone-else", user: { id: "other", name: "Other" } }, + { + instanceId, + user: { id: "profile-1", email: "ada@example.test", name: "Ada" }, + }, + ], + }, + }); + + expect(gateway.snapshot.selfUser).toEqual({ + id: "profile-1", + email: "ada@example.test", + name: "Ada", + }); + + gateway.updateSelfUser?.({ name: "Augusta Ada", avatarUrl: "/api/users/profile-1/avatar?v=2" }); + expect(gateway.snapshot.selfUser).toMatchObject({ + id: "profile-1", + name: "Augusta Ada", + avatarUrl: "/api/users/profile-1/avatar?v=2", + }); + + current().opts.onEvent?.({ + type: "event", + event: "presence", + payload: { + presence: [ + { + instanceId, + user: { + id: "profile-1", + email: "ada@example.test", + name: "Ada Lovelace", + avatarUrl: "/api/users/profile-1/avatar?v=3", + }, + }, + ], + }, + seq: 1, + stateVersion: { presence: 1, health: 1 }, + }); + expect(gateway.snapshot.selfUser).toMatchObject({ + id: "profile-1", + name: "Ada Lovelace", + avatarUrl: "/api/users/profile-1/avatar?v=3", + }); + + current().opts.onEvent?.({ + type: "event", + event: "presence", + payload: { presence: [{ instanceId: "anonymous" }] }, + seq: 2, + stateVersion: { presence: 2, health: 1 }, + }); + expect(gateway.snapshot.selfUser).toBeNull(); + }); + + it("clears identity while disconnected", () => { + const { gateway, current } = createStore(); + gateway.start(); + current().opts.onHello?.({ + ...HELLO, + snapshot: { + presence: [ + { instanceId: current().opts.instanceId, user: { id: "profile-1", name: "Ada" } }, + ], + }, + }); + + current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true }); + + expect(gateway.snapshot.selfUser).toBeNull(); + }); + it("does not copy selected-remote settings into an ephemeral document Gateway", () => { const pageGateway = "ws://127.0.0.1:18789"; const remoteGateway = "wss://saved-remote.example.test"; diff --git a/ui/src/app/gateway-store.ts b/ui/src/app/gateway-store.ts index 68272fa2027b..a0d2b9889362 100644 --- a/ui/src/app/gateway-store.ts +++ b/ui/src/app/gateway-store.ts @@ -16,11 +16,24 @@ import type { ApplicationGatewaySnapshot, } from "./context.ts"; import { loadSettings, patchSettings, persistSessionToken } from "./settings.ts"; +import { readPresenceEntries, resolveSelfPresenceUser } from "./user-profile.ts"; type GatewayClientFactory = (opts: GatewayBrowserClientOptions) => GatewayBrowserClient; const defaultClientFactory: GatewayClientFactory = (opts) => new GatewayBrowserClient(opts); +function sameSelfUser( + left: ApplicationGatewaySnapshot["selfUser"], + right: ApplicationGatewaySnapshot["selfUser"], +): boolean { + return ( + left?.id === right?.id && + left?.email === right?.email && + left?.name === right?.name && + left?.avatarUrl === right?.avatarUrl + ); +} + export function createApplicationGateway( initialSettings: ReturnType, initialPassword = "", @@ -45,6 +58,7 @@ export function createApplicationGateway( sessionKey: settings.sessionKey, lastError: null, lastErrorCode: null, + selfUser: null, }; let client: GatewayBrowserClient | null = null; // Session lineage for this page lifetime: once a hello succeeded, later @@ -96,6 +110,15 @@ export function createApplicationGateway( settings = patchSettings(patch, { selectGateway }); }; const recordGatewayEvent = (event: Parameters[0]) => { + if (event.event === "presence") { + const entries = readPresenceEntries(event.payload); + if (entries) { + const selfUser = resolveSelfPresenceUser(entries, client?.instanceId); + if (!sameSelfUser(snapshot.selfUser, selfUser)) { + setSnapshot({ ...snapshot, selfUser }); + } + } + } eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice( 0, 250, @@ -177,6 +200,10 @@ export function createApplicationGateway( sessionKey, lastError: null, lastErrorCode: null, + selfUser: resolveSelfPresenceUser( + readPresenceEntries(hello.snapshot) ?? [], + nextClient.instanceId, + ), }); }, onRecoveryScopeChange: () => { @@ -195,6 +222,7 @@ export function createApplicationGateway( connected: false, reconnecting: everConnected && willRetry, hello: null, + selfUser: null, lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`, lastErrorCode: error?.code ?? null, }); @@ -222,6 +250,7 @@ export function createApplicationGateway( // recovery, banner "retry now") when a session already existed. reconnecting: everConnected, hello: null, + selfUser: null, sessionKey: nextSessionKey, lastError: null, lastErrorCode: null, @@ -264,6 +293,7 @@ export function createApplicationGateway( connected: false, reconnecting: false, hello: null, + selfUser: null, lastError: null, lastErrorCode: null, }); @@ -285,6 +315,12 @@ export function createApplicationGateway( } }; }, + updateSelfUser: (patch) => { + if (!snapshot.selfUser) { + return; + } + setSnapshot({ ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } }); + }, }; return gateway; } diff --git a/ui/src/app/gateway.ts b/ui/src/app/gateway.ts index 8ebd101fa046..0005d5e78a64 100644 --- a/ui/src/app/gateway.ts +++ b/ui/src/app/gateway.ts @@ -1,5 +1,6 @@ import type { EventLogEntry } from "../api/event-log.ts"; import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts"; +import type { AuthenticatedUser } from "./user-profile.ts"; export type ApplicationGatewaySnapshot = { client: GatewayBrowserClient | null; @@ -15,6 +16,8 @@ export type ApplicationGatewaySnapshot = { sessionKey: string; lastError: string | null; lastErrorCode: string | null; + /** Identity projected from this browser connection's own presence entry. */ + selfUser?: AuthenticatedUser | null; }; export type ApplicationGatewayConnection = { @@ -39,4 +42,5 @@ export type ApplicationGateway = { subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void; subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void; subscribeEvents: (listener: GatewayEventListener) => () => void; + updateSelfUser?: (patch: Partial>) => void; }; diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 22adac89bf64..f45ce3fac654 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -7,7 +7,6 @@ import { loadSettings, persistSessionToken, resolvePageGatewaySettings, - saveLocalUserIdentity, saveSettings, type UiSettings, } from "./settings.ts"; @@ -1078,20 +1077,6 @@ describe("loadSettings default gateway URL derivation", () => { }); }); - it("persists and clears normalized local user identity", () => { - expect(saveLocalUserIdentity({ name: " Buns ", avatar: " 🦞 " })).toEqual({ - name: "Buns", - avatar: "🦞", - }); - expect(loadLocalUserIdentity()).toEqual({ name: "Buns", avatar: "🦞" }); - - expect(saveLocalUserIdentity({ name: null, avatar: null })).toEqual({ - name: null, - avatar: null, - }); - expect(localStorage.getItem("openclaw.control.user.v1")).toBeNull(); - }); - it("normalizes invalid local user identity values on load", () => { localStorage.setItem( "openclaw.control.user.v1", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index e2ba6f50d151..a751a1643840 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -518,22 +518,6 @@ export function loadLocalUserIdentity(): LocalUserIdentity { } } -export function saveLocalUserIdentity(next: LocalUserIdentity): LocalUserIdentity { - const storage = getSafeLocalStorage(); - const normalized = normalizeLocalUserIdentity(next); - try { - if (normalized.name === null && normalized.avatar === null) { - storage?.removeItem(LOCAL_USER_IDENTITY_KEY); - } else { - storage?.setItem(LOCAL_USER_IDENTITY_KEY, JSON.stringify(normalized)); - } - } catch { - // best-effort β€” quota exceeded or security restrictions should not - // prevent in-memory identity updates from being applied - } - return normalized; -} - function persistSettings(next: UiSettings, options: { selectGateway?: boolean } = {}) { persistSessionToken(next.gatewayUrl, next.token); const storage = getSafeLocalStorage(); diff --git a/ui/src/app/user-profile.test.ts b/ui/src/app/user-profile.test.ts new file mode 100644 index 000000000000..3346166c4e1b --- /dev/null +++ b/ui/src/app/user-profile.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + readPresenceEntries, + resolveCurrentSelfUser, + resolveSelfPresenceUser, + userProfileAvatarUrl, +} from "./user-profile.ts"; + +describe("connection user profile helpers", () => { + it("resolves identity only from the current live presence entry", () => { + const entries = [ + { instanceId: "other", user: { id: "other-profile", name: "Other" } }, + { instanceId: "self", user: { id: "old", name: "Old" }, reason: "disconnect" }, + { instanceId: "self", user: { id: "profile-1", name: "Ada" } }, + ]; + + expect(resolveSelfPresenceUser(entries, "self")).toEqual({ id: "profile-1", name: "Ada" }); + expect(resolveSelfPresenceUser(entries, "anonymous")).toBeNull(); + expect(resolveSelfPresenceUser(entries, undefined)).toBeNull(); + }); + + it("prefers locally refreshed identity state over the presence snapshot", () => { + const presenceEntries = [{ instanceId: "self", user: { id: "profile-1", name: "Ada" } }]; + + expect( + resolveCurrentSelfUser({ + snapshotUser: { id: "profile-1", name: "Augusta Ada" }, + presenceEntries, + presenceInstanceId: "self", + }), + ).toEqual({ id: "profile-1", name: "Augusta Ada" }); + expect(resolveCurrentSelfUser({ presenceEntries, presenceInstanceId: "self" })).toEqual({ + id: "profile-1", + name: "Ada", + }); + expect( + resolveCurrentSelfUser({ + snapshotUser: { id: "previous-profile", name: "Previous User" }, + presenceEntries, + presenceInstanceId: "self", + }), + ).toEqual({ id: "profile-1", name: "Ada" }); + }); + + it("reads presence payloads and builds scoped cache-busted avatar URLs", () => { + const entries = [{ instanceId: "self", user: { id: "profile/1" } }]; + expect(readPresenceEntries({ presence: entries })).toEqual(entries); + expect(readPresenceEntries({ presence: null })).toBeUndefined(); + expect( + userProfileAvatarUrl( + "wss://gateway.example.test/control", + "profile/1", + 42, + "https://gateway.example.test/control/profile", + ), + ).toBe("https://gateway.example.test/api/users/profile%2F1/avatar?v=42"); + expect( + userProfileAvatarUrl( + "wss://remote.example.test", + "profile-1", + 42, + "https://gateway.example.test/control/profile", + ), + ).toBeNull(); + }); +}); diff --git a/ui/src/app/user-profile.ts b/ui/src/app/user-profile.ts new file mode 100644 index 000000000000..3417b8333dc9 --- /dev/null +++ b/ui/src/app/user-profile.ts @@ -0,0 +1,77 @@ +import type { PresenceEntry } from "../api/types.ts"; + +export type AuthenticatedUser = NonNullable; + +export function readPresenceEntries(value: unknown): PresenceEntry[] | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const presence = (value as { presence?: unknown }).presence; + return Array.isArray(presence) ? (presence as PresenceEntry[]) : undefined; +} + +export function resolveSelfPresenceUser( + entries: readonly PresenceEntry[], + instanceId: string | undefined, +): AuthenticatedUser | null { + if (!instanceId) { + return null; + } + const entry = entries.find( + (candidate) => candidate.instanceId === instanceId && candidate.reason !== "disconnect", + ); + return entry?.user?.id ? entry.user : null; +} + +/** Prefers local profile edits for the current presence identity only. */ +export function resolveCurrentSelfUser({ + snapshotUser, + presenceEntries, + presenceInstanceId, +}: { + snapshotUser?: AuthenticatedUser | null; + presenceEntries?: readonly PresenceEntry[]; + presenceInstanceId?: string; +}): AuthenticatedUser | null { + const presenceUser = resolveSelfPresenceUser(presenceEntries ?? [], presenceInstanceId); + // Gateway state folds newer presence into snapshotUser, so a matching profile is + // either the latest presence projection or the local profile edit it should retain. + return snapshotUser && (!presenceUser || snapshotUser.id === presenceUser.id) + ? snapshotUser + : presenceUser; +} + +export function userProfileAvatarUrl( + gatewayUrl: string, + profileId: string, + updatedAt: number, + documentHref = globalThis.location?.href, +): string | null { + if (!documentHref) { + return null; + } + try { + const url = new URL(gatewayUrl, documentHref); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } else if (url.protocol === "wss:") { + url.protocol = "https:"; + } + // The authenticated avatar endpoint is HTTP-only and the Control UI CSP + // permits images from its own origin. Cross-origin gateways keep initials. + if ( + !["http:", "https:"].includes(url.protocol) || + url.origin !== new URL(documentHref).origin + ) { + return null; + } + url.username = ""; + url.password = ""; + url.pathname = `/api/users/${encodeURIComponent(profileId)}/avatar`; + url.search = `?v=${updatedAt}`; + url.hash = ""; + return url.href; + } catch { + return null; + } +} diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 343a9d016dee..f4cd1e98a5fb 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -9,6 +9,7 @@ import { pathForRoute } from "../app-route-paths.ts"; import { sessionHasPendingApproval } from "../app/approval-presentation.ts"; import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; +import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts"; import { t } from "../i18n/index.ts"; import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts"; import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; @@ -295,6 +296,14 @@ class AppSidebar extends AppSidebarSessionListElement { const gatewayStatus = t("chat.gatewayStatus", { status: this.connected ? t("common.online") : t("common.offline"), }); + const selfUser = this.connected + ? resolveCurrentSelfUser({ + snapshotUser: this.context?.gateway.snapshot.selfUser, + presenceEntries: readPresenceEntries(this.presencePayload), + presenceInstanceId: this.presenceInstanceId, + }) + : null; + const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id; return html`