diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 511a578a7..9eb52adbd 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -54,6 +54,7 @@ const WorkbenchWire = type({ pinned: "boolean", "definitionId?": "string | null", participants: "unknown[]", + "owner?": type({ address: "string", handle: "string" }).or("null"), "legacy?": "boolean", // Row signals `GET /workbenches` annotates when it can resolve a // workbench's mailbox (see `packages/chat/src/routes.ts`): absent, diff --git a/packages/chat-ui/src/chat-members.tsx b/packages/chat-ui/src/chat-members.tsx new file mode 100644 index 000000000..37ad89b76 --- /dev/null +++ b/packages/chat-ui/src/chat-members.tsx @@ -0,0 +1,194 @@ +import { useId } from "react"; +import { + Avatar, + Button, + Menu, + MenuContent, + MenuItem, + MenuTrigger, + useDismissablePopover, +} from "@corbits/react-ui"; +import { CaretDown, DotsThree, Plus } from "@corbits/icons"; + +import type { WorkbenchAgent } from "./api"; +import { CorbitAvatar } from "./avatar"; +import type { TeamAvatarEntry } from "./chat-workspace"; +import { localPartOf } from "./timeline"; + +export function ChatMembers({ + members, + agents, + currentUserPrincipalId, + onInvite, + onEditAgent, +}: { + readonly members: readonly TeamAvatarEntry[]; + readonly agents: readonly WorkbenchAgent[]; + readonly currentUserPrincipalId: string | undefined; + readonly onInvite: (() => void) | undefined; + readonly onEditAgent: ((definitionId: string) => void) | undefined; +}) { + const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< + HTMLDivElement, + HTMLButtonElement + >(); + const panelId = useId(); + 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); + + return ( +
+ + {open ? ( +
+

Members

+

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

+ + {onInvite !== undefined ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index 247112953..c1f89f6c8 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -66,6 +66,28 @@ 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"]); + }); + + 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"); + }); + 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 fbe4ec088..32dd9881a 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,8 +115,8 @@ import { applyStreamReaction, useWorkbenchFeed, } from "./use-workbench-feed"; -import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; -import { useWorkbenchPresenceRoster } from "./workbench-presence"; +import { avatarClassForPrincipal } from "./avatar"; +import { ChatMembers } from "./chat-members"; import { type } from "arktype"; import { ChatMessageEventData, @@ -153,22 +152,8 @@ 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 to the one shape the square stack renders. 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; @@ -177,11 +162,6 @@ export interface TeamAvatarEntry { readonly avatarClassName?: string; } -/** 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. */ -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 = { @@ -206,8 +186,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 @@ -217,7 +196,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) => { @@ -232,12 +217,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 { @@ -896,8 +881,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 @@ -953,7 +936,6 @@ function ChatWorkspaceInner({ handleTypingEvent(eventType, data); handleStreamingReplyEvent(eventType, data); handleTurnActivityEvent(eventType, data); - handlePresenceEvent(eventType, data); if (activeWorkbenchId === null) return; switch (eventType) { case "chat.message": { @@ -1323,43 +1305,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], - ); - - // Static member stack (square) vs live presence (round) — never one - // combined circular team stack. + // Membership includes offline participants. const memberStack = buildMemberAvatarStack( activeWorkbench?.participants ?? [], agentDisplayNames, currentUser, + activeWorkbench?.owner, ); - const visibleMemberStack = memberStack.slice(0, TEAM_AVATAR_STACK_LIMIT); - const memberStackOverflow = memberStack.length - visibleMemberStack.length; - const visiblePresenceStack = presenceMembers.slice( - 0, - TEAM_AVATAR_STACK_LIMIT, - ); - const presenceStackOverflow = - presenceMembers.length - visiblePresenceStack.length; const showRoomChrome = workbenchesState.kind === "ready" && @@ -1417,78 +1369,22 @@ function ChatWorkspaceInner({ ) : null} - {visibleMemberStack.length > 0 ? ( -
- {visibleMemberStack.map((entry) => - entry.tone === "agent" ? ( - - - - ) : ( - - {entry.initials} - - ), - )} - {memberStackOverflow > 0 ? ( - - +{memberStackOverflow} - - ) : null} -
- ) : null} - {presenceMembers.length > 0 ? ( -
- {visiblePresenceStack.map((member) => ( - - {member.displayName.slice(0, 1).toUpperCase()} - - ))} - {presenceStackOverflow > 0 ? ( - - +{presenceStackOverflow} - - ) : null} -
- ) : null} - {offerInviteControl ? ( - + {activeWorkbenchId !== null && + (memberStack.length > 0 || offerInviteControl) ? ( + setInviteDialogOpen(true) : undefined + } + onEditAgent={ + onSettingsOpenChange !== undefined + ? (definitionId) => openWorkbenchSettings("agents", definitionId) + : undefined + } + /> ) : null}