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"}
+
+
+ {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 ? (
+
+ ) : null}
+
+ );
+ })}
+
+ {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}