diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 577190fe5..d301afb90 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -103,7 +103,7 @@ function Shell({ ); return ( - + diff --git a/apps/web/src/navigation.tsx b/apps/web/src/navigation.tsx index 6a1c46507..7777bff1e 100644 --- a/apps/web/src/navigation.tsx +++ b/apps/web/src/navigation.tsx @@ -7,6 +7,8 @@ import { createContext, useContext } from "react"; import type { ComponentProps, MouseEvent, ReactNode } from "react"; +import type { SessionUser } from "./session"; + export type Navigate = (to: string) => void; const NavigateContext = createContext(() => { @@ -20,19 +22,28 @@ const NavigateContext = createContext(() => { * crashing. */ const SignOutContext = createContext<(() => void) | undefined>(undefined); +/** Same availability rule as `SignOutContext`: present in the signed-in + * shell so surfaces like `ChatPage` can label the reader's own avatar from + * the auth account (CL-6655), undefined outside that shell. */ +const SessionUserContext = createContext(undefined); + export function NavigationProvider({ navigate, onSignOut, + user, children, }: { readonly navigate: Navigate; readonly onSignOut?: () => void; + readonly user?: SessionUser; readonly children: ReactNode; }) { return ( - {children} + + {children} + ); @@ -48,6 +59,12 @@ export function useSignOut(): (() => void) | undefined { return useContext(SignOutContext); } +/** The signed-in account from the shell session probe — `undefined` outside + * a signed-in `NavigationProvider` (onboarding, package tests). */ +export function useSessionUser(): SessionUser | undefined { + return useContext(SessionUserContext); +} + /** * Intercepts a plain left click on an in-app anchor. Modified clicks (new * tab, download) and clicks a handler already cancelled keep their native diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 425507f27..429f17f9f 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -20,7 +20,8 @@ import { createChatBlockResponseActions } from "../block-response-actions"; import { createChatConnectGithubActions } from "../connect-github-actions"; import { createChatConnectServiceActions } from "../connect-service-actions"; import { useBench } from "../bench-context"; -import { useSignOut } from "../navigation"; +import { useSignOut, useSessionUser } from "../navigation"; + import { artifactContentFromBlob, artifactContentFromBlobError, @@ -63,7 +64,9 @@ export function ChatPage({ }) { const bench = useBench(); const onSignIn = useSignOut(); + const sessionUser = useSessionUser(); const workbenchId = workbenchIdFromPath(path); + const settingsOpen = isWorkbenchSettingsPath(path); const settingsSection = workbenchSettingsSectionFromPath(path) ?? "general"; const settingsEntityId = settingsOpen @@ -78,6 +81,26 @@ export function ChatPage({ const queryClient = useQueryClient(); const tenantId = bench.selectedTenantId; + // Same display name the sidebar account row already shows (CL-6655): the + // auth session's name, which sign-up seeds from the email local-part when + // no profile name was typed. Without this, chat-ui falls back to "Member" + // for the reader's own presence/message avatar. + const currentUser = + principalId === undefined + ? undefined + : { + principalId, + ...(sessionUser !== undefined + ? { + name: + sessionUser.name.trim().length > 0 + ? sessionUser.name.trim() + : (sessionUser.email.split("@")[0] ?? sessionUser.email), + handle: sessionUser.email, + } + : {}), + }; + // Files' workbench-first lens (CL-6353) reads this back to default to // "this workbench" when the person just came from one. useEffect(() => { @@ -230,7 +253,7 @@ export function ChatPage({ const workspace = ( navigate(workbenchPath(nextWorkbenchId)) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 7cb26e5bd..30d8618e9 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -1047,12 +1047,13 @@ function ChatWorkspaceInner({ displayName: typingLabel( member.principalId, activeWorkbench?.participants ?? [], + currentUser, ), color: style["--avatar-identity-bg"], textColor: style["--avatar-identity-fg"], }; }), - [presenceRoster, activeWorkbench?.participants], + [presenceRoster, activeWorkbench?.participants, currentUser], ); // Team stack: every active agent + live human for the top bar. @@ -1442,6 +1443,7 @@ function ChatWorkspaceInner({ label={typingLabel( typingState.principalId, activeWorkbench?.participants ?? [], + currentUser, )} /> ) : ( diff --git a/packages/chat-ui/src/typing-indicator.tsx b/packages/chat-ui/src/typing-indicator.tsx index 93f822840..868adfc06 100644 --- a/packages/chat-ui/src/typing-indicator.tsx +++ b/packages/chat-ui/src/typing-indicator.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef, useState } from "react"; import type { ParticipantRecord } from "./api"; -import { localPartOf } from "./timeline"; +import { localPartOf, type CurrentUser } from "./timeline"; import { CHAT_STRINGS } from "./strings"; /** How long a `chat.typing` ping stays reflected in the banner before it's @@ -64,12 +64,23 @@ export function isTypingStateExpired(state: TypingState, now: number): boolean { return state !== null && state.expiresAt <= now; } -/** A typing principal's friendly handle — falls back to the same - * deterministic "Member" label the timeline uses, never a raw address. */ +/** A typing/presence principal's friendly label — prefers the signed-in + * reader's own `currentUser.name` when the principal is self (CL-6655), + * else the participant handle, else the same deterministic "Member" + * fallback the timeline uses. Never a raw address. */ export function typingLabel( principalId: string, participants: readonly ParticipantRecord[], + currentUser?: CurrentUser, ): string { + if ( + currentUser !== undefined && + currentUser.principalId === principalId && + currentUser.name !== undefined && + currentUser.name.trim().length > 0 + ) { + return currentUser.name.trim(); + } const match = participants.find( (participant) => localPartOf(participant.address) === principalId, ); diff --git a/packages/chat-ui/test/components.test.tsx b/packages/chat-ui/test/components.test.tsx index 99bec22e9..44669c268 100644 --- a/packages/chat-ui/test/components.test.tsx +++ b/packages/chat-ui/test/components.test.tsx @@ -834,6 +834,15 @@ describe("typingLabel", () => { test("falls back to the deterministic Member label with no matching participant", () => { expect(typingLabel("prn_unknown1", [])).toBe("Member"); }); + + test("prefers currentUser.name for the signed-in principal (CL-6655)", () => { + expect( + typingLabel("prn_self", [], { + principalId: "prn_self", + name: "sawyer", + }), + ).toBe("sawyer"); + }); }); describe("TypingIndicator", () => { diff --git a/packages/chat-ui/test/team-avatar-stack.test.tsx b/packages/chat-ui/test/team-avatar-stack.test.tsx index 2d163c29c..153a10d55 100644 --- a/packages/chat-ui/test/team-avatar-stack.test.tsx +++ b/packages/chat-ui/test/team-avatar-stack.test.tsx @@ -243,4 +243,36 @@ describe("workbench header team avatar stack", () => { expect(overflow?.textContent).toBe("+1"); harness.unmount(); }); + + test("own presence avatar uses currentUser.name, never Member (CL-6655)", async () => { + // The signed-in reader is live in presence but not yet on the workbench + // participants list (or has no handle there) — without currentUser.name + // the stack title falls back to "Member". + stubFetch({ + participants: [{ address: "myra@agents.example", handle: "Myra" }], + }); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + currentUser: { principalId: "prn_self", name: "sawyer" }, + }); + await harness.settle(); + act(() => { + firstStream().emit("chat.presence.snapshot", { + members: [ + { principalId: "prn_self", lastActiveAt: "2026-01-01T00:00:00Z" }, + ], + }); + }); + await harness.settle(); + + const presenceAvatars = harness.container.querySelectorAll( + ".chat-presence-avatar:not([data-agent])", + ); + expect(presenceAvatars).toHaveLength(1); + expect((presenceAvatars[0] as HTMLElement).title).toBe("sawyer"); + expect((presenceAvatars[0] as HTMLElement).title).not.toBe("Member"); + expect(presenceAvatars[0]?.textContent).toBe("S"); + harness.unmount(); + }); });