From 6dc289fa54588fb05ecfd4dc239665f7bb001897 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 10 Sep 2026 18:10:08 +0545 Subject: [PATCH 1/4] Group conversation members in a compact header popover --- packages/chat-ui/src/chat-members.tsx | 234 ++++++++++++++++++ packages/chat-ui/src/chat-workspace.tsx | 85 +++---- packages/chat-ui/src/styles.css | 29 --- packages/chat-ui/test/chat-members.test.tsx | 178 +++++++++++++ packages/chat-ui/test/chat-workspace.test.tsx | 76 +++++- .../chat-ui/test/team-avatar-stack.test.tsx | 18 +- 6 files changed, 522 insertions(+), 98 deletions(-) create mode 100644 packages/chat-ui/src/chat-members.tsx create mode 100644 packages/chat-ui/test/chat-members.test.tsx diff --git a/packages/chat-ui/src/chat-members.tsx b/packages/chat-ui/src/chat-members.tsx new file mode 100644 index 000000000..345f48807 --- /dev/null +++ b/packages/chat-ui/src/chat-members.tsx @@ -0,0 +1,234 @@ +import { useId, useState } from "react"; +import { + Avatar, + Button, + ConfirmButton, + useDismissablePopover, +} from "@corbits/react-ui"; +import { CaretDown, DotsThree, Plus } from "@corbits/icons"; +import { reportError } from "@corbits/error-sink"; + +import { describeChatError, removeWorkbenchParticipant } from "./api"; +import type { WorkbenchAgent } from "./api"; +import { CorbitAvatar } from "./avatar"; +import type { TeamAvatarEntry } from "./chat-workspace"; +import { localPartOf } from "./timeline"; +import { CHAT_STRINGS } from "./strings"; + +export function ChatMembers({ + tenantId, + workbenchId, + members, + agents, + currentUserPrincipalId, + canRemove, + onInvite, + onEditAgent, + onParticipantsChanged, +}: { + readonly tenantId: string; + readonly workbenchId: string; + readonly members: readonly TeamAvatarEntry[]; + readonly agents: readonly WorkbenchAgent[]; + readonly currentUserPrincipalId: string | undefined; + readonly canRemove: boolean; + readonly onInvite: (() => void) | undefined; + readonly onEditAgent: ((definitionId: string) => void) | undefined; + readonly onParticipantsChanged: () => void; +}) { + const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< + HTMLDivElement, + HTMLButtonElement + >(); + const panelId = useId(); + const [removing, setRemoving] = useState(null); + const [error, setError] = useState(null); + const agentCount = members.filter((member) => member.tone === "agent").length; + const peopleCount = members.length - agentCount; + const representatives = [ + members.find((member) => member.tone === "agent"), + members.find((member) => member.tone === "neutral"), + ].filter((member) => member !== undefined); + + const preview = + representatives.length === 2 ? representatives : members.slice(0, 2); + + async function remove(address: string) { + setRemoving(address); + setError(null); + try { + await removeWorkbenchParticipant(tenantId, workbenchId, address); + onParticipantsChanged(); + } catch (cause) { + const refId = reportError(cause, { + operation: "chat.removeMember", + tenantId, + roomId: workbenchId, + }); + setError( + `${describeChatError(cause, CHAT_STRINGS.workbenchSettingsRemoveError)} (${refId})`, + ); + } finally { + setRemoving(null); + } + } + + return ( +
+ + {open ? ( +
+

Members

+

+ {peopleCount} {peopleCount === 1 ? "person" : "people"} ·{" "} + {agentCount} {agentCount === 1 ? "agent" : "agents"} +

+
    + {members.map((member) => { + const self = + member.tone === "neutral" && + localPartOf(member.key) === currentUserPrincipalId; + const agent = agents.find( + (candidate) => candidate.address === member.key, + ); + const editable = agent !== undefined && onEditAgent !== undefined; + return ( +
  • + {member.tone === "agent" ? ( + + ) : ( + + )} +
    +

    + {member.label} +

    +

    + {self + ? "You" + : member.tone === "agent" + ? "Agent" + : "Member"} +

    +
    + {editable || (canRemove && !self) ? ( +
    + + +
    + {editable ? ( + + ) : null} + {canRemove && !self ? ( + { + void remove(member.key); + }} + > + {removing === member.key + ? CHAT_STRINGS.workbenchSettingsRemoving + : CHAT_STRINGS.workbenchSettingsRemoveAction} + + ) : null} +
    +
    + ) : null} +
  • + ); + })} +
+ {error !== null ? ( +

+ {error} +

+ ) : null} + {onInvite !== undefined ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index fbe4ec088..fac80fae2 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -19,7 +19,6 @@ import { CaretDown, ChatCircle, SlidersHorizontal, - UserPlus, WarningCircle, } from "@corbits/icons"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -116,7 +115,8 @@ import { applyStreamReaction, useWorkbenchFeed, } from "./use-workbench-feed"; -import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; +import { avatarClassForPrincipal } from "./avatar"; +import { ChatMembers } from "./chat-members"; import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { @@ -167,7 +167,7 @@ export interface PresenceMember { } /** One entry in the header's static member stack — an agent or a roster - * human, normalized to the one shape the square stack renders. Live + * human, normalized for the member control and popover. Live * presence uses `PresenceMember` in a separate round stack. */ export interface TeamAvatarEntry { readonly key: string; @@ -178,8 +178,7 @@ export interface TeamAvatarEntry { } /** How many avatars the header shows before collapsing the rest into a - * "+N" chip. Shared by the static member stack and the live presence - * stack so neither overflows the 3rem bar. */ + * "+N" chip in the live presence stack. */ export const TEAM_AVATAR_STACK_LIMIT = 6; /** A crumb the host's `StageTopBar` can render — label plus an optional @@ -1345,15 +1344,12 @@ function ChatWorkspaceInner({ [presenceRoster, activeWorkbench?.participants, currentUser], ); - // Static member stack (square) vs live presence (round) — never one - // combined circular team stack. + // Membership includes offline participants; presence is tracked separately. const memberStack = buildMemberAvatarStack( activeWorkbench?.participants ?? [], agentDisplayNames, currentUser, ); - const visibleMemberStack = memberStack.slice(0, TEAM_AVATAR_STACK_LIMIT); - const memberStackOverflow = memberStack.length - visibleMemberStack.length; const visiblePresenceStack = presenceMembers.slice( 0, TEAM_AVATAR_STACK_LIMIT, @@ -1417,44 +1413,29 @@ function ChatWorkspaceInner({ ) : null} - {visibleMemberStack.length > 0 ? ( -
- {visibleMemberStack.map((entry) => - entry.tone === "agent" ? ( - - - - ) : ( - - {entry.initials} - - ), - )} - {memberStackOverflow > 0 ? ( - - +{memberStackOverflow} - - ) : null} -
+ {activeWorkbenchId !== null && + (memberStack.length > 0 || offerInviteControl) ? ( + setInviteDialogOpen(true) : undefined + } + onEditAgent={ + onSettingsOpenChange !== undefined + ? (definitionId) => openWorkbenchSettings("agents", definitionId) + : undefined + } + onParticipantsChanged={() => { + refreshWorkbenchLists(); + void workbenchAgentsQuery.refetch(); + }} + /> ) : null} {presenceMembers.length > 0 ? (
) : null} - {offerInviteControl ? ( - - ) : null}
- {editable || (canRemove && !self) ? ( -
- + + + + event.stopPropagation()} + onCloseAutoFocus={(event) => { + if (open) return; + event.preventDefault(); + triggerRef.current?.focus(); + }} + onPointerDown={(event) => event.stopPropagation()} > - -
{editable ? ( - + ) : null} - {canRemove && !self ? ( - { - void remove(member.key); - }} + {canRemove && !self && !member.isOwner ? ( + event.preventDefault()} > - {removing === member.key - ? CHAT_STRINGS.workbenchSettingsRemoving - : CHAT_STRINGS.workbenchSettingsRemoveAction} - + { + void remove(member.key); + }} + > + {removing === member.key + ? CHAT_STRINGS.workbenchSettingsRemoving + : CHAT_STRINGS.workbenchSettingsRemoveAction} + + ) : null} -
-
+ + ) : null} ); diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index 247112953..6831ae568 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -66,6 +66,30 @@ describe("buildMemberAvatarStack", () => { expect(stack.map((entry) => entry.initials)).toEqual(["A"]); }); + test("includes the native owner missing from the mention roster, never the viewer", () => { + const stack = buildMemberAvatarStack( + [{ address: "myra@agents.example", handle: "myra" }], + undefined, + { principalId: "prn_bob", name: "Bob" }, + { address: "prn_alice", handle: "Alice" }, + ); + expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Alice"]); + expect(stack[1]?.isOwner).toBe(true); + }); + + test("does not duplicate an owner already in the roster and protects that row", () => { + const owner = { address: "prn_alice", handle: "Alice" }; + const stack = buildMemberAvatarStack( + [owner], + undefined, + { principalId: "prn_alice", name: "Alice Smith" }, + owner, + ); + expect(stack).toHaveLength(1); + expect(stack[0]?.label).toBe("Alice Smith"); + expect(stack[0]?.isOwner).toBe(true); + }); + test("prefers resolved agent display names over handle slugs (CL-6424)", () => { const participants: readonly ParticipantRecord[] = [ { address: "run_myra@dana.localhost", handle: "myra" }, diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index fac80fae2..883576544 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -117,7 +117,6 @@ import { } from "./use-workbench-feed"; import { avatarClassForPrincipal } from "./avatar"; import { ChatMembers } from "./chat-members"; -import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { ChatMessageEventData, @@ -153,34 +152,17 @@ export type TenantResolution = | { readonly kind: "empty" } | { readonly kind: "ready"; readonly tenantId: string }; -/** - * One live presence entry for the workbench's who's-here stack (CL-6328) — - * derived from this workbench's own `/stream` connection - * (`useWorkbenchPresenceRoster`), never a second connection or an HTTP - * heartbeat poll. The roster carries only ids, so display names and avatar - * classes resolve client-side against the workbench's participants. - */ -export interface PresenceMember { - readonly principalId: string; - readonly displayName: string; - readonly avatarClassName: string; -} - /** One entry in the header's static member stack — an agent or a roster - * human, normalized for the member control and popover. Live - * presence uses `PresenceMember` in a separate round stack. */ + * human, normalized for the member control and popover. */ export interface TeamAvatarEntry { readonly key: string; readonly initials: string; readonly label: string; readonly tone: "agent" | "neutral"; readonly avatarClassName?: string; + readonly isOwner?: boolean; } -/** How many avatars the header shows before collapsing the rest into a - * "+N" chip in the live presence stack. */ -export const TEAM_AVATAR_STACK_LIMIT = 6; - /** A crumb the host's `StageTopBar` can render — label plus an optional * parent href. The last crumb is the current page. */ export type ChatHeaderCrumb = { @@ -205,8 +187,7 @@ const WORKBENCHES_LIST_CHROME: ChatHeaderChrome = { * human on the roster. Agents first (they have no presence concept of * their own); humans follow. Roster humans are included even when live * presence is empty (CL-6779) — onboarding/template rooms often list the - * signed-in human as a participant before any `chat.presence.snapshot` - * arrives. Live who's-here is a separate round stack, not mixed in here. + * signed-in human as a participant before any live update arrives. * * Each person gets a stable generated color keyed by address. Human labels * prefer `currentUser.name` when the roster entry @@ -216,7 +197,13 @@ export function buildMemberAvatarStack( participants: readonly ParticipantRecord[], displayNames?: AgentDisplayNames, currentUser?: CurrentUser, + owner?: ParticipantRecord | null, ): readonly TeamAvatarEntry[] { + const roster = + owner != null && + !participants.some((participant) => participant.address === owner.address) + ? [...participants, owner] + : participants; const agents = participants .filter((participant) => isAgentAddress(participant.address)) .map((participant) => { @@ -231,12 +218,12 @@ export function buildMemberAvatarStack( }; }); - const humans = participants + const humans = roster .filter((participant) => !isAgentAddress(participant.address)) .map((participant) => { const label = typingLabel( localPartOf(participant.address), - participants, + roster, currentUser, ); return { @@ -244,6 +231,7 @@ export function buildMemberAvatarStack( initials: label.slice(0, 1).toUpperCase(), label, tone: "neutral" as const, + ...(participant.address === owner?.address ? { isOwner: true } : {}), avatarClassName: avatarClassForPrincipal(participant.address), }; }); @@ -895,8 +883,6 @@ function ChatWorkspaceInner({ } = useStreamingReply(activeWorkbenchId); const { activity: turnActivity, handleStreamEvent: handleTurnActivityEvent } = useTurnActivity(activeWorkbenchId); - const { roster: presenceRoster, handleStreamEvent: handlePresenceEvent } = - useWorkbenchPresenceRoster(activeWorkbenchId); // "Here at all" comes for free from the open `/stream` connection itself // (see `packages/chat/src/workbench-presence.ts`) — this ping only @@ -952,7 +938,6 @@ function ChatWorkspaceInner({ handleTypingEvent(eventType, data); handleStreamingReplyEvent(eventType, data); handleTurnActivityEvent(eventType, data); - handlePresenceEvent(eventType, data); if (activeWorkbenchId === null) return; switch (eventType) { case "chat.message": { @@ -1322,40 +1307,13 @@ function ChatWorkspaceInner({ !workbenchGone && messagesState.kind !== "ready"; - // Who's live in this workbench right now, beyond the static participants - // list — derived from this workbench's own `chat.presence`/ - // `chat.presence.snapshot` stream events (CL-6328), never a second - // connection or an HTTP heartbeat poll. Display name and color are - // resolved client-side (the roster itself carries only ids) the same way - // `typingLabel` resolves a typing ping's principal. - const presenceMembers: readonly PresenceMember[] = useMemo( - () => - presenceRoster.map((member) => { - return { - principalId: member.principalId, - displayName: typingLabel( - member.principalId, - activeWorkbench?.participants ?? [], - currentUser, - ), - avatarClassName: avatarClassForPrincipal(member.principalId), - }; - }), - [presenceRoster, activeWorkbench?.participants, currentUser], - ); - - // Membership includes offline participants; presence is tracked separately. + // Membership includes offline participants. const memberStack = buildMemberAvatarStack( activeWorkbench?.participants ?? [], agentDisplayNames, currentUser, + activeWorkbench?.owner, ); - const visiblePresenceStack = presenceMembers.slice( - 0, - TEAM_AVATAR_STACK_LIMIT, - ); - const presenceStackOverflow = - presenceMembers.length - visiblePresenceStack.length; const showRoomChrome = workbenchesState.kind === "ready" && @@ -1437,30 +1395,6 @@ function ChatWorkspaceInner({ }} /> ) : null} - {presenceMembers.length > 0 ? ( -
- {visiblePresenceStack.map((member) => ( - - {member.displayName.slice(0, 1).toUpperCase()} - - ))} - {presenceStackOverflow > 0 ? ( - - +{presenceStackOverflow} - - ) : null} -
- ) : null}
- {editable || (canRemove && !self && !member.isOwner) ? ( + {editable ? ( ) : null} @@ -231,11 +173,6 @@ export function ChatMembers({ ); })} - {error !== null ? ( -

- {error} -

- ) : null} {onInvite !== undefined ? (