Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ function Shell({
);
return (
<QueryClientProvider client={queryClient}>
<NavigationProvider navigate={navigate} onSignOut={onSignOut}>
<NavigationProvider navigate={navigate} onSignOut={onSignOut} user={user}>
<BenchProvider>
<ProviderHealthProvider>
<ComposerInsertionProvider>
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Navigate>(() => {
Expand All @@ -20,19 +22,28 @@ const NavigateContext = createContext<Navigate>(() => {
* 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<SessionUser | undefined>(undefined);

export function NavigationProvider({
navigate,
onSignOut,
user,
children,
}: {
readonly navigate: Navigate;
readonly onSignOut?: () => void;
readonly user?: SessionUser;
readonly children: ReactNode;
}) {
return (
<NavigateContext.Provider value={navigate}>
<SignOutContext.Provider value={onSignOut}>
{children}
<SessionUserContext.Provider value={user}>
{children}
</SessionUserContext.Provider>
</SignOutContext.Provider>
</NavigateContext.Provider>
);
Expand All @@ -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
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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(() => {
Expand Down Expand Up @@ -230,7 +253,7 @@ export function ChatPage({
const workspace = (
<ChatWorkspace
tenant={tenant}
{...(principalId !== undefined ? { currentUser: { principalId } } : {})}
{...(currentUser !== undefined ? { currentUser } : {})}
workbenchId={workbenchId}
onWorkbenchChange={(nextWorkbenchId) =>
navigate(workbenchPath(nextWorkbenchId))
Expand Down
4 changes: 3 additions & 1 deletion packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1442,6 +1443,7 @@ function ChatWorkspaceInner({
label={typingLabel(
typingState.principalId,
activeWorkbench?.participants ?? [],
currentUser,
)}
/>
) : (
Expand Down
17 changes: 14 additions & 3 deletions packages/chat-ui/src/typing-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
Expand Down
9 changes: 9 additions & 0 deletions packages/chat-ui/test/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
32 changes: 32 additions & 0 deletions packages/chat-ui/test/team-avatar-stack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading