From 7f2fc9565bcfcd89486907f999bc1b2729a27a13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:43:48 -0700 Subject: [PATCH] Include signed-in human in onboarding member stack Closes CL-6779 --- packages/chat-ui/src/chat-workspace.test.ts | 37 ++++++++++++ packages/chat-ui/src/chat-workspace.tsx | 66 +++++++++++++++++---- 2 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index b274f7d09..6b63a1a58 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -48,4 +48,41 @@ describe("buildTeamAvatarStack (CL-6594)", () => { "Dana", ]); }); + + test("includes the signed-in human from the roster even with empty presence (CL-6779)", () => { + // Onboarding/template rooms list the human as a participant before any + // presence snapshot arrives — the stack must not be agent-only. + const participants: readonly ParticipantRecord[] = [ + { address: "run_myra@dana.localhost", handle: "myra" }, + { address: "prn_dana", handle: "Dana" }, + ]; + + const stack = buildTeamAvatarStack(participants, []); + + expect(stack.map((entry) => entry.label)).toEqual(["myra", "Dana"]); + expect(stack.map((entry) => entry.tone)).toEqual(["agent", "neutral"]); + const human = stack[1]; + expect(human?.key).toBe("prn_dana"); + expect(human?.initials).toBe("D"); + expect(human?.color).toBeDefined(); + }); + + test("dedupes a roster human who is also live in presence", () => { + const participants: readonly ParticipantRecord[] = [ + { address: "run_myra@dana.localhost", handle: "myra" }, + { address: "prn_dana", handle: "Dana" }, + ]; + + const stack = buildTeamAvatarStack(participants, [ + { + principalId: "prn_dana", + displayName: "Dana Live", + color: "hsl(10 70% 60%)", + textColor: "#000000", + }, + ]); + + expect(stack.map((entry) => entry.label)).toEqual(["myra", "Dana Live"]); + expect(stack[1]?.color).toBe("hsl(10 70% 60%)"); + }); }); diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 7cb26e5bd..47b3296c2 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -153,8 +153,12 @@ export const TEAM_AVATAR_STACK_LIMIT = 6; * Every currently-active member of the workbench, for the header's * overlapping avatar stack: every agent participant on the workbench (agents * are always "active" — they have no presence concept of their own) plus - * every human currently reflected in live presence. Agents first since - * they're a workbench's stable roster; humans are who's here right now. + * every human on the roster and every human currently reflected in live + * presence. Agents first since they're a workbench's stable roster; humans + * follow. Roster humans are included even when presence is empty + * (CL-6779) — onboarding/template rooms often have the signed-in human as + * a participant before any `chat.presence.snapshot` arrives, and omitting + * them left the stack agent-only. * * Each agent gets its own `generatedAvatarStyle` fill keyed by address * (CL-6594) — the same deterministic-per-principal machinery humans @@ -179,15 +183,55 @@ export function buildTeamAvatarStack( textColor: style["--avatar-identity-fg"], }; }); - const humans = presenceMembers.map((member) => ({ - key: member.principalId, - initials: member.displayName.slice(0, 1).toUpperCase(), - label: member.displayName, - tone: "neutral" as const, - color: member.color, - textColor: member.textColor, - })); - return [...agents, ...humans]; + + const presenceById = new Map( + presenceMembers.map((member) => [member.principalId, member] as const), + ); + const rosterHumans = participants.filter( + (participant) => !isAgentAddress(participant.address), + ); + const rosterHumanIds = new Set( + rosterHumans.map((participant) => participant.address), + ); + + // Prefer live-presence display name/color when the roster human is also + // present; otherwise fall back to the participant handle + generated fill + // so onboarding/template rooms still show the signed-in human. + const humansFromRoster = rosterHumans.map((participant) => { + const live = presenceById.get(participant.address); + if (live !== undefined) { + return { + key: live.principalId, + initials: live.displayName.slice(0, 1).toUpperCase(), + label: live.displayName, + tone: "neutral" as const, + color: live.color, + textColor: live.textColor, + }; + } + const style = generatedAvatarStyle(participant.address); + return { + key: participant.address, + initials: participant.handle.slice(0, 1).toUpperCase(), + label: participant.handle, + tone: "neutral" as const, + color: style["--avatar-identity-bg"], + textColor: style["--avatar-identity-fg"], + }; + }); + + const humansFromPresenceOnly = presenceMembers + .filter((member) => !rosterHumanIds.has(member.principalId)) + .map((member) => ({ + key: member.principalId, + initials: member.displayName.slice(0, 1).toUpperCase(), + label: member.displayName, + tone: "neutral" as const, + color: member.color, + textColor: member.textColor, + })); + + return [...agents, ...humansFromRoster, ...humansFromPresenceOnly]; } type WorkbenchesState =