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
37 changes: 37 additions & 0 deletions packages/chat-ui/src/chat-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%)");
});
});
66 changes: 55 additions & 11 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 =
Expand Down
Loading