Skip to content

Commit 82cfcbc

Browse files
committed
fix(ui): footer identity chip prefers locally updated self profile
1 parent c576960 commit 82cfcbc

8 files changed

Lines changed: 127 additions & 9 deletions

File tree

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,35 @@ describe("createApplicationGateway reconnecting snapshot", () => {
201201
current().opts.onEvent?.({
202202
type: "event",
203203
event: "presence",
204-
payload: { presence: [{ instanceId: "anonymous" }] },
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+
},
205217
seq: 1,
206218
stateVersion: { presence: 1, health: 1 },
207219
});
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+
});
208233
expect(gateway.snapshot.selfUser).toBeNull();
209234
});
210235

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
readPresenceEntries,
4+
resolveCurrentSelfUser,
45
resolveSelfPresenceUser,
56
userProfileAvatarUrl,
67
} from "./user-profile.ts";
@@ -18,6 +19,29 @@ describe("connection user profile helpers", () => {
1819
expect(resolveSelfPresenceUser(entries, undefined)).toBeNull();
1920
});
2021

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+
2145
it("reads presence payloads and builds scoped cache-busted avatar URLs", () => {
2246
const entries = [{ instanceId: "self", user: { id: "profile/1" } }];
2347
expect(readPresenceEntries({ presence: entries })).toEqual(entries);

ui/src/app/user-profile.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,24 @@ export function resolveSelfPresenceUser(
2323
return entry?.user?.id ? entry.user : null;
2424
}
2525

26+
/** Prefers local profile edits for the current presence identity only. */
27+
export function resolveCurrentSelfUser({
28+
snapshotUser,
29+
presenceEntries,
30+
presenceInstanceId,
31+
}: {
32+
snapshotUser?: AuthenticatedUser | null;
33+
presenceEntries?: readonly PresenceEntry[];
34+
presenceInstanceId?: string;
35+
}): AuthenticatedUser | null {
36+
const presenceUser = resolveSelfPresenceUser(presenceEntries ?? [], presenceInstanceId);
37+
// Gateway state folds newer presence into snapshotUser, so a matching profile is
38+
// either the latest presence projection or the local profile edit it should retain.
39+
return snapshotUser && (!presenceUser || snapshotUser.id === presenceUser.id)
40+
? snapshotUser
41+
: presenceUser;
42+
}
43+
2644
export function userProfileAvatarUrl(
2745
gatewayUrl: string,
2846
profileId: string,

ui/src/components/app-sidebar.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { pathForRoute } from "../app-route-paths.ts";
99
import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
1010
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
1111
import { controlUiPublicAssetPath } from "../app/public-assets.ts";
12-
import { readPresenceEntries, resolveSelfPresenceUser } from "../app/user-profile.ts";
12+
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
1313
import { t } from "../i18n/index.ts";
1414
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
1515
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
@@ -296,10 +296,13 @@ class AppSidebar extends AppSidebarSessionListElement {
296296
const gatewayStatus = t("chat.gatewayStatus", {
297297
status: this.connected ? t("common.online") : t("common.offline"),
298298
});
299-
const selfUser = resolveSelfPresenceUser(
300-
readPresenceEntries(this.presencePayload) ?? [],
301-
this.presenceInstanceId,
302-
);
299+
const selfUser = this.connected
300+
? resolveCurrentSelfUser({
301+
snapshotUser: this.context?.gateway.snapshot.selfUser,
302+
presenceEntries: readPresenceEntries(this.presencePayload),
303+
presenceInstanceId: this.presenceInstanceId,
304+
})
305+
: null;
303306
const selfLabel = selfUser?.name ?? selfUser?.email ?? selfUser?.id;
304307
return html`
305308
<div class="sidebar-footer-bar">

ui/src/pages/profile/profile-page.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ it("bootstraps and refreshes the connected user's profile through users.self", a
381381
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.value).toBe(
382382
"Augusta Ada",
383383
);
384+
expect(harness.context.gateway.snapshot.selfUser?.name).toBe("Augusta Ada");
384385

385386
const displayNameInput = page.querySelector<HTMLInputElement>(".identity-name-control input")!;
386387
displayNameInput.value = "Unsaved draft";
@@ -418,6 +419,9 @@ it("bootstraps and refreshes the connected user's profile through users.self", a
418419
expect(request.mock.calls.filter(([method]) => method === "users.self")).toHaveLength(3),
419420
);
420421
await page.updateComplete;
422+
expect(harness.context.gateway.snapshot.selfUser?.avatarUrl).toContain(
423+
"/api/users/profile-1/avatar?v=4",
424+
);
421425
expect(page.querySelector<HTMLInputElement>(".identity-name-control input")?.value).toBe(
422426
"Unsaved draft",
423427
);

ui/src/pages/profile/profile-page.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type ApplicationGatewaySnapshot,
1717
} from "../../app/context.ts";
1818
import type { AuthenticatedUser } from "../../app/user-profile.ts";
19-
import { userProfileAvatarUrl } from "../../app/user-profile.ts";
19+
import { resolveCurrentSelfUser, userProfileAvatarUrl } from "../../app/user-profile.ts";
2020
import { icons } from "../../components/icons.ts";
2121
import {
2222
renderSettingsEmpty,
@@ -176,7 +176,9 @@ export class ProfilePage extends OpenClawLightDomElement {
176176
private applyGatewaySnapshot(snapshot: ApplicationGatewaySnapshot) {
177177
const clientChanged = snapshot.client !== this.client;
178178
const becameConnected = snapshot.connected && !this.connected;
179-
const nextSelfUser = snapshot.connected ? (snapshot.selfUser ?? null) : null;
179+
const nextSelfUser = snapshot.connected
180+
? resolveCurrentSelfUser({ snapshotUser: snapshot.selfUser })
181+
: null;
180182
const selfProfileChanged = nextSelfUser?.id !== this.selfUser?.id;
181183
this.client = snapshot.client;
182184
this.connected = snapshot.connected;

ui/src/test-helpers/app-sidebar-cases/basics.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,19 @@ describe("AppSidebar viewer presence", () => {
5252
gatewayHarness.gateway,
5353
createSessions("main", ["agent:main:main", "agent:main:work"]),
5454
);
55+
sidebar.connected = true;
5556
const onNavigate = vi.fn();
5657
sidebar.onNavigate = onNavigate;
5758

5859
gatewayHarness.publishEvent("presence", {
5960
presence: [
6061
{
6162
instanceId: "self-instance",
62-
user: { id: "00-self", name: "Self User" },
63+
user: {
64+
id: "00-self",
65+
name: "Self User",
66+
avatarUrl: "/api/users/00-self/avatar?v=1",
67+
},
6368
watchedSessions: ["agent:main:work"],
6469
},
6570
{
@@ -95,6 +100,14 @@ describe("AppSidebar viewer presence", () => {
95100
],
96101
});
97102
await sidebar.updateComplete;
103+
gatewayHarness.publish({
104+
selfUser: {
105+
id: "00-self",
106+
name: "Self User",
107+
avatarUrl: "/api/users/00-self/avatar?v=1",
108+
},
109+
});
110+
await sidebar.updateComplete;
98111

99112
const sessionFacepile = sidebar.querySelector<HTMLElement>(
100113
'[data-session-key="agent:main:work"] openclaw-viewer-facepile',
@@ -143,6 +156,24 @@ describe("AppSidebar viewer presence", () => {
143156
expect(onNavigate).toHaveBeenCalledWith("profile", {
144157
hash: "#settings-profile-identity",
145158
});
159+
160+
const avatar = identityChip?.querySelector<HTMLImageElement>("openclaw-viewer-avatar img");
161+
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=1");
162+
gatewayHarness.gateway.updateSelfUser?.({
163+
name: "Augusta Ada",
164+
avatarUrl: "/api/users/00-self/avatar?v=4",
165+
});
166+
await sidebar.updateComplete;
167+
168+
// Profile mutations update gateway state directly; no presence event follows them.
169+
expect(identityChip?.querySelector(".sidebar-footer-bar__identity-name")?.textContent).toBe(
170+
"Augusta Ada",
171+
);
172+
expect(avatar?.getAttribute("src")).toBe("/api/users/00-self/avatar?v=4");
173+
174+
sidebar.connected = false;
175+
await sidebar.updateComplete;
176+
expect(sidebar.querySelector(".sidebar-footer-bar__identity")).toBeNull();
146177
});
147178

148179
it("leaves the footer identity chip absent for an unidentified connection", async () => {

ui/src/test-helpers/app-sidebar.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ export function createGatewayHarness(client: GatewayBrowserClient) {
8787
eventListeners.add(listener);
8888
return () => eventListeners.delete(listener);
8989
},
90+
updateSelfUser(
91+
patch: Partial<Omit<NonNullable<ApplicationGatewaySnapshot["selfUser"]>, "id">>,
92+
) {
93+
if (!snapshot.selfUser) {
94+
return;
95+
}
96+
snapshot = { ...snapshot, selfUser: { ...snapshot.selfUser, ...patch } };
97+
for (const listener of listeners) {
98+
listener(snapshot);
99+
}
100+
},
90101
} as unknown as ApplicationGateway;
91102
return {
92103
gateway,

0 commit comments

Comments
 (0)