Skip to content

Commit c95a8e3

Browse files
authored
feat(ui): identity settings and own-identity chip (#111371)
* feat(ui): identity settings and own-identity chip * feat(ui): resolve own identity via users.self * fix(ui): rebase identity settings onto landed identity stack * fix(ui): footer identity chip prefers locally updated self profile * fix(ui): settings CI conformance — split profile page, lean exports
1 parent 3b84a55 commit c95a8e3

25 files changed

Lines changed: 1539 additions & 916 deletions

ui/src/app/app-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,7 @@ class OpenClawShell extends OpenClawLightDomElement {
12901290
schema: runtimeConfig.configSchema,
12911291
value: runtimeConfig.configForm ?? runtimeConfig.configSnapshot?.config ?? null,
12921292
uiHints: runtimeConfig.configUiHints,
1293+
identityAvailable: Boolean(gatewaySnapshot.selfUser),
12931294
});
12941295
const onboarding = this.onboardingMode;
12951296
const navDrawerOpen = this.navDrawerOpen && !onboarding;

ui/src/app/gateway-store.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ const HELLO: GatewayHelloOk = {
1818
class FakeGatewayClient {
1919
started = 0;
2020
stopped = 0;
21+
readonly instanceId: string;
2122

22-
constructor(readonly opts: GatewayBrowserClientOptions) {}
23+
constructor(readonly opts: GatewayBrowserClientOptions) {
24+
this.instanceId = opts.instanceId ?? "";
25+
}
2326

2427
start() {
2528
this.started += 1;
@@ -165,6 +168,88 @@ describe("createApplicationGateway reconnecting snapshot", () => {
165168
expect(gateway.snapshot.reconnecting).toBe(true);
166169
});
167170

171+
it("projects only this browser connection's optional presence identity", () => {
172+
const { gateway, current } = createStore();
173+
gateway.start();
174+
const instanceId = current().opts.instanceId;
175+
current().opts.onHello?.({
176+
...HELLO,
177+
snapshot: {
178+
presence: [
179+
{ instanceId: "someone-else", user: { id: "other", name: "Other" } },
180+
{
181+
instanceId,
182+
user: { id: "profile-1", email: "[email protected]", name: "Ada" },
183+
},
184+
],
185+
},
186+
});
187+
188+
expect(gateway.snapshot.selfUser).toEqual({
189+
id: "profile-1",
190+
191+
name: "Ada",
192+
});
193+
194+
gateway.updateSelfUser?.({ name: "Augusta Ada", avatarUrl: "/api/users/profile-1/avatar?v=2" });
195+
expect(gateway.snapshot.selfUser).toMatchObject({
196+
id: "profile-1",
197+
name: "Augusta Ada",
198+
avatarUrl: "/api/users/profile-1/avatar?v=2",
199+
});
200+
201+
current().opts.onEvent?.({
202+
type: "event",
203+
event: "presence",
204+
payload: {
205+
presence: [
206+
{
207+
instanceId,
208+
user: {
209+
id: "profile-1",
210+
211+
name: "Ada Lovelace",
212+
avatarUrl: "/api/users/profile-1/avatar?v=3",
213+
},
214+
},
215+
],
216+
},
217+
seq: 1,
218+
stateVersion: { presence: 1, health: 1 },
219+
});
220+
expect(gateway.snapshot.selfUser).toMatchObject({
221+
id: "profile-1",
222+
name: "Ada Lovelace",
223+
avatarUrl: "/api/users/profile-1/avatar?v=3",
224+
});
225+
226+
current().opts.onEvent?.({
227+
type: "event",
228+
event: "presence",
229+
payload: { presence: [{ instanceId: "anonymous" }] },
230+
seq: 2,
231+
stateVersion: { presence: 2, health: 1 },
232+
});
233+
expect(gateway.snapshot.selfUser).toBeNull();
234+
});
235+
236+
it("clears identity while disconnected", () => {
237+
const { gateway, current } = createStore();
238+
gateway.start();
239+
current().opts.onHello?.({
240+
...HELLO,
241+
snapshot: {
242+
presence: [
243+
{ instanceId: current().opts.instanceId, user: { id: "profile-1", name: "Ada" } },
244+
],
245+
},
246+
});
247+
248+
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
249+
250+
expect(gateway.snapshot.selfUser).toBeNull();
251+
});
252+
168253
it("does not copy selected-remote settings into an ephemeral document Gateway", () => {
169254
const pageGateway = "ws://127.0.0.1:18789";
170255
const remoteGateway = "wss://saved-remote.example.test";

ui/src/app/gateway-store.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,24 @@ import type {
1616
ApplicationGatewaySnapshot,
1717
} from "./context.ts";
1818
import { loadSettings, patchSettings, persistSessionToken } from "./settings.ts";
19+
import { readPresenceEntries, resolveSelfPresenceUser } from "./user-profile.ts";
1920

2021
type GatewayClientFactory = (opts: GatewayBrowserClientOptions) => GatewayBrowserClient;
2122

2223
const defaultClientFactory: GatewayClientFactory = (opts) => new GatewayBrowserClient(opts);
2324

25+
function sameSelfUser(
26+
left: ApplicationGatewaySnapshot["selfUser"],
27+
right: ApplicationGatewaySnapshot["selfUser"],
28+
): boolean {
29+
return (
30+
left?.id === right?.id &&
31+
left?.email === right?.email &&
32+
left?.name === right?.name &&
33+
left?.avatarUrl === right?.avatarUrl
34+
);
35+
}
36+
2437
export function createApplicationGateway(
2538
initialSettings: ReturnType<typeof loadSettings>,
2639
initialPassword = "",
@@ -45,6 +58,7 @@ export function createApplicationGateway(
4558
sessionKey: settings.sessionKey,
4659
lastError: null,
4760
lastErrorCode: null,
61+
selfUser: null,
4862
};
4963
let client: GatewayBrowserClient | null = null;
5064
// Session lineage for this page lifetime: once a hello succeeded, later
@@ -96,6 +110,15 @@ export function createApplicationGateway(
96110
settings = patchSettings(patch, { selectGateway });
97111
};
98112
const recordGatewayEvent = (event: Parameters<GatewayEventListener>[0]) => {
113+
if (event.event === "presence") {
114+
const entries = readPresenceEntries(event.payload);
115+
if (entries) {
116+
const selfUser = resolveSelfPresenceUser(entries, client?.instanceId);
117+
if (!sameSelfUser(snapshot.selfUser, selfUser)) {
118+
setSnapshot({ ...snapshot, selfUser });
119+
}
120+
}
121+
}
99122
eventLog = [{ ts: Date.now(), event: event.event, payload: event.payload }, ...eventLog].slice(
100123
0,
101124
250,
@@ -177,6 +200,10 @@ export function createApplicationGateway(
177200
sessionKey,
178201
lastError: null,
179202
lastErrorCode: null,
203+
selfUser: resolveSelfPresenceUser(
204+
readPresenceEntries(hello.snapshot) ?? [],
205+
nextClient.instanceId,
206+
),
180207
});
181208
},
182209
onRecoveryScopeChange: () => {
@@ -195,6 +222,7 @@ export function createApplicationGateway(
195222
connected: false,
196223
reconnecting: everConnected && willRetry,
197224
hello: null,
225+
selfUser: null,
198226
lastError: error?.message ?? `disconnected (${code}): ${reason || "no reason"}`,
199227
lastErrorCode: error?.code ?? null,
200228
});
@@ -222,6 +250,7 @@ export function createApplicationGateway(
222250
// recovery, banner "retry now") when a session already existed.
223251
reconnecting: everConnected,
224252
hello: null,
253+
selfUser: null,
225254
sessionKey: nextSessionKey,
226255
lastError: null,
227256
lastErrorCode: null,
@@ -264,6 +293,7 @@ export function createApplicationGateway(
264293
connected: false,
265294
reconnecting: false,
266295
hello: null,
296+
selfUser: null,
267297
lastError: null,
268298
lastErrorCode: null,
269299
});
@@ -285,6 +315,12 @@ export function createApplicationGateway(
285315
}
286316
};
287317
},
318+
updateSelfUser: (patch) => {
319+
if (!snapshot.selfUser) {
320+
return;
321+
}
322+
setSnapshot({ ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } });
323+
},
288324
};
289325
return gateway;
290326
}

ui/src/app/gateway.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EventLogEntry } from "../api/event-log.ts";
22
import type { GatewayBrowserClient, GatewayEventListener, GatewayHelloOk } from "../api/gateway.ts";
3+
import type { AuthenticatedUser } from "./user-profile.ts";
34

45
export type ApplicationGatewaySnapshot = {
56
client: GatewayBrowserClient | null;
@@ -15,6 +16,8 @@ export type ApplicationGatewaySnapshot = {
1516
sessionKey: string;
1617
lastError: string | null;
1718
lastErrorCode: string | null;
19+
/** Identity projected from this browser connection's own presence entry. */
20+
selfUser?: AuthenticatedUser | null;
1821
};
1922

2023
export type ApplicationGatewayConnection = {
@@ -39,4 +42,5 @@ export type ApplicationGateway = {
3942
subscribe: (listener: (snapshot: ApplicationGatewaySnapshot) => void) => () => void;
4043
subscribeEventLog: (listener: (events: readonly EventLogEntry[]) => void) => () => void;
4144
subscribeEvents: (listener: GatewayEventListener) => () => void;
45+
updateSelfUser?: (patch: Partial<Omit<AuthenticatedUser, "id">>) => void;
4246
};

ui/src/app/settings.node.test.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
loadSettings,
88
persistSessionToken,
99
resolvePageGatewaySettings,
10-
saveLocalUserIdentity,
1110
saveSettings,
1211
type UiSettings,
1312
} from "./settings.ts";
@@ -1078,20 +1077,6 @@ describe("loadSettings default gateway URL derivation", () => {
10781077
});
10791078
});
10801079

1081-
it("persists and clears normalized local user identity", () => {
1082-
expect(saveLocalUserIdentity({ name: " Buns ", avatar: " 🦞 " })).toEqual({
1083-
name: "Buns",
1084-
avatar: "🦞",
1085-
});
1086-
expect(loadLocalUserIdentity()).toEqual({ name: "Buns", avatar: "🦞" });
1087-
1088-
expect(saveLocalUserIdentity({ name: null, avatar: null })).toEqual({
1089-
name: null,
1090-
avatar: null,
1091-
});
1092-
expect(localStorage.getItem("openclaw.control.user.v1")).toBeNull();
1093-
});
1094-
10951080
it("normalizes invalid local user identity values on load", () => {
10961081
localStorage.setItem(
10971082
"openclaw.control.user.v1",

ui/src/app/settings.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -518,22 +518,6 @@ export function loadLocalUserIdentity(): LocalUserIdentity {
518518
}
519519
}
520520

521-
export function saveLocalUserIdentity(next: LocalUserIdentity): LocalUserIdentity {
522-
const storage = getSafeLocalStorage();
523-
const normalized = normalizeLocalUserIdentity(next);
524-
try {
525-
if (normalized.name === null && normalized.avatar === null) {
526-
storage?.removeItem(LOCAL_USER_IDENTITY_KEY);
527-
} else {
528-
storage?.setItem(LOCAL_USER_IDENTITY_KEY, JSON.stringify(normalized));
529-
}
530-
} catch {
531-
// best-effort — quota exceeded or security restrictions should not
532-
// prevent in-memory identity updates from being applied
533-
}
534-
return normalized;
535-
}
536-
537521
function persistSettings(next: UiSettings, options: { selectGateway?: boolean } = {}) {
538522
persistSessionToken(next.gatewayUrl, next.token);
539523
const storage = getSafeLocalStorage();

ui/src/app/user-profile.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
readPresenceEntries,
4+
resolveCurrentSelfUser,
5+
resolveSelfPresenceUser,
6+
userProfileAvatarUrl,
7+
} from "./user-profile.ts";
8+
9+
describe("connection user profile helpers", () => {
10+
it("resolves identity only from the current live presence entry", () => {
11+
const entries = [
12+
{ instanceId: "other", user: { id: "other-profile", name: "Other" } },
13+
{ instanceId: "self", user: { id: "old", name: "Old" }, reason: "disconnect" },
14+
{ instanceId: "self", user: { id: "profile-1", name: "Ada" } },
15+
];
16+
17+
expect(resolveSelfPresenceUser(entries, "self")).toEqual({ id: "profile-1", name: "Ada" });
18+
expect(resolveSelfPresenceUser(entries, "anonymous")).toBeNull();
19+
expect(resolveSelfPresenceUser(entries, undefined)).toBeNull();
20+
});
21+
22+
it("prefers locally refreshed identity state over the presence snapshot", () => {
23+
const presenceEntries = [{ instanceId: "self", user: { id: "profile-1", name: "Ada" } }];
24+
25+
expect(
26+
resolveCurrentSelfUser({
27+
snapshotUser: { id: "profile-1", name: "Augusta Ada" },
28+
presenceEntries,
29+
presenceInstanceId: "self",
30+
}),
31+
).toEqual({ id: "profile-1", name: "Augusta Ada" });
32+
expect(resolveCurrentSelfUser({ presenceEntries, presenceInstanceId: "self" })).toEqual({
33+
id: "profile-1",
34+
name: "Ada",
35+
});
36+
expect(
37+
resolveCurrentSelfUser({
38+
snapshotUser: { id: "previous-profile", name: "Previous User" },
39+
presenceEntries,
40+
presenceInstanceId: "self",
41+
}),
42+
).toEqual({ id: "profile-1", name: "Ada" });
43+
});
44+
45+
it("reads presence payloads and builds scoped cache-busted avatar URLs", () => {
46+
const entries = [{ instanceId: "self", user: { id: "profile/1" } }];
47+
expect(readPresenceEntries({ presence: entries })).toEqual(entries);
48+
expect(readPresenceEntries({ presence: null })).toBeUndefined();
49+
expect(
50+
userProfileAvatarUrl(
51+
"wss://gateway.example.test/control",
52+
"profile/1",
53+
42,
54+
"https://gateway.example.test/control/profile",
55+
),
56+
).toBe("https://gateway.example.test/api/users/profile%2F1/avatar?v=42");
57+
expect(
58+
userProfileAvatarUrl(
59+
"wss://remote.example.test",
60+
"profile-1",
61+
42,
62+
"https://gateway.example.test/control/profile",
63+
),
64+
).toBeNull();
65+
});
66+
});

0 commit comments

Comments
 (0)