diff --git a/src/gateway/server/ws-connection.test.ts b/src/gateway/server/ws-connection.test.ts index 08e900a5ad85..ec12fb70be31 100644 --- a/src/gateway/server/ws-connection.test.ts +++ b/src/gateway/server/ws-connection.test.ts @@ -15,11 +15,13 @@ const { attachGatewayWsMessageHandlerMock, attachWorkerWsMessageHandlerMock, broadcastPresenceSnapshotMock, + touchPresenceMock, upsertPresenceMock, } = vi.hoisted(() => ({ attachGatewayWsMessageHandlerMock: vi.fn(), attachWorkerWsMessageHandlerMock: vi.fn((_params: unknown) => vi.fn()), broadcastPresenceSnapshotMock: vi.fn(), + touchPresenceMock: vi.fn(), upsertPresenceMock: vi.fn(), })); @@ -30,6 +32,7 @@ vi.mock("./ws-connection/worker-connection.js", () => ({ attachWorkerWsMessageHandler: attachWorkerWsMessageHandlerMock, })); vi.mock("../../infra/system-presence.js", () => ({ + touchPresence: touchPresenceMock, upsertPresence: upsertPresenceMock, })); vi.mock("./presence-events.js", () => ({ @@ -88,6 +91,7 @@ describe("attachGatewayWsConnectionHandler", () => { attachGatewayWsMessageHandlerMock.mockReset(); attachWorkerWsMessageHandlerMock.mockClear(); broadcastPresenceSnapshotMock.mockReset(); + touchPresenceMock.mockReset(); upsertPresenceMock.mockReset(); }); @@ -240,13 +244,16 @@ describe("attachGatewayWsConnectionHandler", () => { socket, connect: { client: { id: "openclaw-control-ui", mode: "webchat" } }, connId: "ping-client", + presenceKey: "ping-client", usesSharedGatewayAuth: false, }), ).toBe(true); vi.advanceTimersByTime(25_000); expect(socket.ping).toHaveBeenCalledTimes(1); + expect(touchPresenceMock).not.toHaveBeenCalled(); socket.emit("pong"); + expect(touchPresenceMock).toHaveBeenCalledWith("ping-client"); vi.advanceTimersByTime(25_000); expect(socket.ping).toHaveBeenCalledTimes(2); diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index f5feb236d831..5cceeffe4c1b 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -6,7 +6,7 @@ import type { RawData, WebSocket, WebSocketServer } from "ws"; import { WORKER_PROTOCOL_MAX_PAYLOAD_BYTES } from "../../../packages/gateway-protocol/src/index.js"; import { GATEWAY_STARTUP_PENDING_CLOSE_CAUSE } from "../../../packages/gateway-protocol/src/startup-unavailable.js"; import { getRuntimeConfig } from "../../config/io.js"; -import { upsertPresence } from "../../infra/system-presence.js"; +import { touchPresence, upsertPresence } from "../../infra/system-presence.js"; import { logRejectedLargePayload } from "../../logging/diagnostic-payload.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import { removeRemoteNodeInfo } from "../../skills/runtime/remote.js"; @@ -454,6 +454,9 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti socket.on("pong", () => { awaitingPong = false; + if (client?.presenceKey) { + touchPresence(client.presenceKey); + } }); const isNoisySwiftPmHelperClose = (userAgent: string | undefined, remote: string | undefined) => diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index 5a23f350d24a..d63b347510d6 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -1,7 +1,12 @@ // Covers in-memory system presence merging and expiry behavior. import { randomUUID } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { listSystemPresence, updateSystemPresence, upsertPresence } from "./system-presence.js"; +import { + listSystemPresence, + touchPresence, + updateSystemPresence, + upsertPresence, +} from "./system-presence.js"; describe("system-presence", () => { afterEach(() => { @@ -144,6 +149,25 @@ describe("system-presence", () => { expect(update.key).toBe(keyPrefix); }); + it("keeps connection-owned presence alive when its heartbeat is acknowledged", () => { + vi.useFakeTimers(); + vi.setSystemTime(Date.now()); + + const connectionId = randomUUID(); + upsertPresence(connectionId, { + host: "control-ui", + instanceId: connectionId, + mode: "webchat", + reason: "connect", + }); + + vi.advanceTimersByTime(4 * 60 * 1000); + expect(touchPresence(connectionId)).toBe(true); + vi.advanceTimersByTime(4 * 60 * 1000); + + expect(listSystemPresence().map((entry) => entry.instanceId)).toContain(connectionId); + }); + it("prunes stale non-self entries after TTL", () => { vi.useFakeTimers(); vi.setSystemTime(Date.now()); diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index ed07cded3795..6d0e19a38d73 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -285,6 +285,20 @@ export function upsertPresence(key: string, presence: Partial) { entries.set(normalizedKey, merged); } +/** Renews an existing connection-owned presence row without recreating expired metadata. */ +export function touchPresence(key: string): boolean { + const normalizedKey = normalizePresenceKey(key); + if (!normalizedKey) { + return false; + } + const existing = entries.get(normalizedKey); + if (!existing) { + return false; + } + entries.set(normalizedKey, { ...existing, ts: Date.now() }); + return true; +} + export function listSystemPresence(): SystemPresence[] { ensureSelfPresence(); // prune expired diff --git a/ui/src/app/gateway-store.test.ts b/ui/src/app/gateway-store.test.ts index c85383558c22..8b1a8e5d4f39 100644 --- a/ui/src/app/gateway-store.test.ts +++ b/ui/src/app/gateway-store.test.ts @@ -243,7 +243,11 @@ describe("createApplicationGateway reconnecting snapshot", () => { seq: 2, stateVersion: { presence: 2, health: 1 }, }); - expect(gateway.snapshot.selfUser).toBeNull(); + expect(gateway.snapshot.selfUser).toMatchObject({ + id: "profile-1", + name: "Ada Lovelace", + avatarUrl: "/api/users/profile-1/avatar?v=3", + }); }); it("clears identity while disconnected", () => { diff --git a/ui/src/app/gateway-store.ts b/ui/src/app/gateway-store.ts index 920dcbbfd183..b9c4d9b0628c 100644 --- a/ui/src/app/gateway-store.ts +++ b/ui/src/app/gateway-store.ts @@ -116,7 +116,9 @@ export function createApplicationGateway( const entries = readPresenceEntries(event.payload); if (entries) { const selfUser = resolveSelfPresenceUser(entries, client?.instanceId); - if (!sameSelfUser(snapshot.selfUser, selfUser)) { + // A live connection owns its authenticated identity until onClose. Older + // gateways can omit still-connected clients after presence TTL pruning. + if (selfUser && !sameSelfUser(snapshot.selfUser, selfUser)) { setSnapshot({ ...snapshot, selfUser }); } } diff --git a/ui/src/e2e/chat-attributed-identity.e2e.test.ts b/ui/src/e2e/chat-attributed-identity.e2e.test.ts new file mode 100644 index 000000000000..e90624b6def5 --- /dev/null +++ b/ui/src/e2e/chat-attributed-identity.e2e.test.ts @@ -0,0 +1,102 @@ +// Control UI E2E tests cover attributed chat identity placement. +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium, expect, type Browser, type Page } from "playwright/test"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let server: ControlUiE2eServer; + +async function captureProof(page: Page, name: string) { + const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim(); + if (!artifactDir) { + return; + } + await fs.mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + path: path.join(artifactDir, name), + }); +} + +describeControlUiE2e("Control UI attributed chat identity", () => { + beforeAll(async () => { + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("uses one avatar placement and keeps shared-thread authors readable", async () => { + const context = await browser.newContext({ viewport: { height: 760, width: 1180 } }); + const page = await context.newPage(); + const now = Date.now(); + await installMockGateway(page, { + presenceUsers: [ + { self: true, id: "profile-riley", name: "Riley", email: "riley@example.test" }, + { id: "profile-colin", name: "Colin", email: "colin@example.test" }, + ], + historyMessages: [ + { + role: "assistant", + content: "The shared thread now keeps every participant easy to identify.", + timestamp: now - 180_000, + }, + { + role: "user", + content: "Can we keep one clear avatar and show who wrote each message?", + timestamp: now - 120_000, + __openclaw: { senderId: "profile-riley", senderName: "Riley" }, + }, + { + role: "assistant", + content: "Yes — one author marker is enough, with the name kept readable.", + timestamp: now - 90_000, + }, + { + role: "user", + content: "This is much easier to scan in a team conversation.", + timestamp: now - 30_000, + __openclaw: { senderId: "profile-colin", senderName: "Colin" }, + }, + ], + }); + + await page.goto(`${server.baseUrl}chat?session=agent%3Amain%3Amain`); + await page.getByText("This is much easier to scan in a team conversation.").waitFor(); + + const userGroups = page.locator(".chat-group.user"); + await expect(userGroups).toHaveCount(2); + await expect(page.locator(".chat-avatar.user")).toHaveCount(2); + + await expect( + page.locator(".chat-group-footer--persistent-identity .chat-sender-name"), + ).toHaveText(["Riley", "Colin"]); + await expect(page.locator(".chat-author-avatar")).toHaveCount(0); + const hoverDetails = userGroups.last().locator(".chat-group-timestamp"); + await expect(hoverDetails).toHaveCSS("opacity", "0"); + await captureProof(page, "after-default.png"); + + await userGroups.last().hover(); + await expect(hoverDetails).toHaveCSS("opacity", "1"); + await expect(page.locator(".chat-author-avatar")).toHaveCount(0); + await captureProof(page, "after-hover.png"); + + await context.close(); + }); +}); diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index aacc902f661c..5aa70757d878 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -52,6 +52,7 @@ import { type QuestionPrompt, } from "../../app/question-prompt.ts"; import { loadSettings, patchSettings } from "../../app/settings.ts"; +import { readPresenceEntries, resolveCurrentSelfUser } from "../../app/user-profile.ts"; import { BROWSER_ANNOTATION_EVENT, type BrowserAnnotationDraft, @@ -3277,6 +3278,12 @@ class ChatPane extends OpenClawLightDomElement { // split; bottom strips do not. const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0); const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH; + const gatewaySnapshot = this.context.gateway.snapshot; + const selfUser = resolveCurrentSelfUser({ + snapshotUser: gatewaySnapshot.selfUser, + presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot), + presenceInstanceId: gatewaySnapshot.client?.instanceId, + }); const props: ChatProps = { transcript: this.transcript, paneId: this.paneId, @@ -3584,9 +3591,9 @@ class ChatPane extends OpenClawLightDomElement { onSplitRatioChange: state.handleSplitRatioChange, assistantName: state.assistantName, assistantAvatar: state.assistantAvatar, - userId: this.context.gateway.snapshot.selfUser?.id ?? null, - userName: this.context.gateway.snapshot.selfUser?.name ?? state.userName, - userAvatar: this.context.gateway.snapshot.selfUser?.avatarUrl ?? state.userAvatar, + userId: selfUser?.id ?? null, + userName: selfUser?.name ?? state.userName, + userAvatar: selfUser?.avatarUrl ?? state.userAvatar, localMediaPreviewRoots: state.localMediaPreviewRoots, embedSandboxMode: state.embedSandboxMode, allowExternalEmbedUrls: state.allowExternalEmbedUrls, diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 4b82f979fd54..e488f7d37401 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1687,7 +1687,7 @@ describe("grouped chat rendering", () => { expect(avatar?.tagName).toBe("DIV"); }); - it("renders a durable sender label and avatar chip in user message metadata", async () => { + it("keeps the sender name visible without duplicating a gutter avatar", () => { const container = document.createElement("div"); const group: MessageGroup = { kind: "group", @@ -1712,6 +1712,7 @@ describe("grouped chat rendering", () => { assistantName: "OpenClaw", assistantAvatar: null, userName: "Local User", + showAvatarGutter: true, }), container, ); @@ -1719,11 +1720,12 @@ describe("grouped chat rendering", () => { expect( container.querySelector(".chat-group.user .chat-sender-name")?.textContent, ).toBe("alice"); - await vi.waitFor(() => { - expect( - container.querySelector(".chat-author-avatar__initials")?.textContent?.trim(), - ).toBe("AE"); - }); + expect( + container.querySelector(".chat-group-footer--persistent-identity .chat-sender-name") + ?.textContent, + ).toBe("alice"); + expect(container.querySelector(".chat-avatar.user")).not.toBeNull(); + expect(container.querySelector(".chat-author-avatar")).toBeNull(); }); it("tints attributed user groups with the sender's stable identity hue", () => { @@ -1790,6 +1792,7 @@ describe("grouped chat rendering", () => { assistantName: "OpenClaw", userId: "profile-1", userName: "Fuller Stack", + showAvatarGutter: true, }, ), container, @@ -1798,9 +1801,13 @@ describe("grouped chat rendering", () => { expect( container.querySelector(".chat-group.user .chat-sender-name")?.textContent, ).toBe("Fuller Stack"); + expect( + container.querySelector(".chat-group-footer--persistent-identity .chat-sender-name") + ?.textContent, + ).toBe("Fuller Stack"); }); - it("renders an author avatar for a user group with sender identity", async () => { + it("renders a compact author avatar when the gutter is hidden", async () => { const container = document.createElement("div"); render( renderMessageGroup( @@ -1823,11 +1830,14 @@ describe("grouped chat rendering", () => { showReasoning: true, showToolCalls: true, assistantName: "OpenClaw", + showAvatarGutter: false, }, ), container, ); + expect(container.querySelector(".chat-avatar.user")).toBeNull(); + expect(container.querySelector(".chat-group-persistent-author")).toBeNull(); await vi.waitFor(() => { expect(container.querySelector(".chat-author-avatar__initials")?.textContent?.trim()).toBe( "AE", @@ -1862,6 +1872,7 @@ describe("grouped chat rendering", () => { showReasoning: true, showToolCalls: true, assistantName: "OpenClaw", + showAvatarGutter: false, }), container, ); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index dc911319d3ba..f907a35e7ac9 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -794,6 +794,7 @@ type RenderMessageGroupOptions = { userId?: string | null; userName?: string | null; userAvatar?: string | null; + showAvatarGutter?: boolean; basePath?: string; localMediaPreviewRoots?: readonly string[]; assistantAttachmentAuthToken?: string | null; @@ -876,6 +877,8 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup : isWorkspaceConflict ? t("chat.workspaceConflict.eventSender") : normalizedRole; + const showAvatarGutter = opts.showAvatarGutter !== false; + const persistUserIdentity = normalizedRole === "user" && showAvatarGutter; const roleClass = normalizedRole === "user" ? "user" @@ -925,20 +928,22 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup class="chat-group tool chat-group--activity chat-group--with-footer" data-chat-row-key=${group.key} > - ${renderChatAvatar( - group.role, - { - name: assistantName, - avatar: opts.assistantAvatar ?? null, - }, - { - name: opts.userName ?? null, - avatar: opts.userAvatar ?? null, - }, - opts.basePath, - opts.assistantAttachmentAuthToken, - group.sender, - )} + ${showAvatarGutter + ? renderChatAvatar( + group.role, + { + name: assistantName, + avatar: opts.assistantAvatar ?? null, + }, + { + name: opts.userName ?? null, + avatar: opts.userAvatar ?? null, + }, + opts.basePath, + opts.assistantAttachmentAuthToken, + group.sender, + ) + : nothing}