diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index a3e6d5dec2..5721abcb9f 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -730,6 +730,15 @@ select:disabled,
color: var(--muted-foreground);
}
+/* A subtitle that owns a control (thread close) must stay fully
+ hit-testable — overflow:hidden on the 3rem bar would clip it. The
+ title trail still gives. */
+.stage-top-bar-sub:has(button) {
+ overflow: visible;
+ flex-shrink: 0;
+ min-width: min-content;
+}
+
.stage-top-bar-actions {
display: flex;
flex-shrink: 0;
diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx
index e3be21125e..afc48fc19f 100644
--- a/apps/web/src/pages/chat-page.tsx
+++ b/apps/web/src/pages/chat-page.tsx
@@ -52,6 +52,7 @@ import {
useOpenProfileInCanvas,
} from "../shell/canvas-availability";
import { useRegisterComposerInsert } from "../shell/composer-insertion";
+import { StageTopBar } from "../shell/stage-top-bar";
import { tenantResolutionFromBench } from "../shell/tenant-resolution";
export function ChatPage({
@@ -304,13 +305,17 @@ export function ChatPage({
onNewWorkbench={() => navigate(NEW_WORKBENCH_PATH)}
onBackToWorkbenchList={() => navigate(workbenchPath(null))}
{...(onSignIn !== undefined ? { onSignIn } : {})}
+ headerSlot={(chrome) => (
+
+ )}
/>
);
- // The conversation itself carries the open workbench's own name inline
- // (see ChatWorkspace's `chat-workbench-header`) — that header IS the
- // stage's page identity here, so no generic `StageTopBar` renders above
- // it (CL-6089: a second "Workbenches" bar over the conversation's own
- // header was a double identity, not two different things).
- return workspace;
+ return
{workspace}
;
}
diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx
index c1e9a6cdb9..d65cfad1fd 100644
--- a/apps/web/test/routes.test.tsx
+++ b/apps/web/test/routes.test.tsx
@@ -2,9 +2,8 @@
// for the suspended route (SSR would only see the Suspense fallback). The
// one route with no stage bar of its own (`/`, see `AppRoute.hasStageTopBar`)
// gets one from `AppShell` itself, so every route ends up titled the same
-// way - except the conversation route (`/w`), whose own conversation header
-// IS its page identity (CL-6089): it renders no generic `StageTopBar` at
-// all.
+// way — including `/w` and `/w/:id`, which title the conversation through
+// `StageTopBar` rather than a second in-stage header.
import { ThemeProvider } from "@corbits/react-ui";
import { afterEach, describe, expect, test } from "bun:test";
@@ -348,8 +347,9 @@ describe("routes render", () => {
return;
}
if (route.path === "/w") {
- expect(stagePageTitle(markup)).toBeUndefined();
- expect(markup).toContain('data-testid="shell-sidebar"');
+ expect(stagePageTitle(markup)).toBe("Workbenches");
+ expect(markup).toContain('data-testid="stage-top-bar"');
+ expect(activeFooterLabel(markup)).toBeUndefined();
return;
}
if (route.path === "/settings") {
@@ -430,5 +430,7 @@ describe("routes render", () => {
test("a /w/:workbenchId deep link waits for tenant resolution", async () => {
const markup = await renderApp("/w/ch_deep");
expect(markup).not.toContain('data-open="true"');
+ expect(markup).toContain('data-testid="stage-top-bar"');
+ expect(stagePageTitle(markup)).toBe("Workbenches");
});
});
diff --git a/apps/web/test/stage-top-bar.test.tsx b/apps/web/test/stage-top-bar.test.tsx
index f5ce75b0d5..f981da0f77 100644
--- a/apps/web/test/stage-top-bar.test.tsx
+++ b/apps/web/test/stage-top-bar.test.tsx
@@ -5,6 +5,7 @@
// invariant that the sidebar mounts on every render of AppShell.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
@@ -56,6 +57,21 @@ describe("StageTopBar", () => {
expect(markup).not.toContain('aria-label="Toggle sidebar"');
expect(markup).not.toContain('aria-label="Collapse sidebar"');
});
+
+ test("an interactive subtitle is not clipped by overflow hidden", () => {
+ const css = readFileSync(
+ new URL("../src/app.css", import.meta.url),
+ "utf8",
+ );
+ const interactive = css
+ .split("}")
+ .find((candidate) =>
+ candidate.includes(".stage-top-bar-sub:has(button)"),
+ );
+ expect(interactive).toBeDefined();
+ expect(interactive).toContain("overflow: visible");
+ expect(interactive).toContain("flex-shrink: 0");
+ });
});
type StubQuery = {
diff --git a/packages/chat-ui/src/agent-display-names.ts b/packages/chat-ui/src/agent-display-names.ts
new file mode 100644
index 0000000000..774d02128c
--- /dev/null
+++ b/packages/chat-ui/src/agent-display-names.ts
@@ -0,0 +1,36 @@
+// Person-facing display names for a workbench's agent participants
+// (CL-6424) — resolved once per workbench off
+// `GET /workbenches/:id/agents`' `displayName`, keyed by participant
+// address. Every surface that names an agent (timeline headers, mention
+// picker, typing pulse, presence stack, empty states, join lines) reads
+// through here so a reader sees the definition's display name — never a
+// raw handle slug. A missing entry falls back to the caller's own
+// slug-derived label: the agents query can still be in flight (or an
+// agent's definition gone) while the timeline already renders.
+
+/** Agent display names by participant address. */
+export type AgentDisplayNames = ReadonlyMap;
+
+/**
+ * Builds the lookup from a `listWorkbenchAgents` snapshot — one entry per
+ * agent, keyed by the same address every participant record carries.
+ */
+export function agentDisplayNamesFromAgents(
+ agents: readonly { readonly address: string; readonly displayName: string }[],
+): AgentDisplayNames {
+ return new Map(
+ agents.map((agent) => [agent.address, agent.displayName] as const),
+ );
+}
+
+/**
+ * The display name for an agent address when one is known — `undefined`
+ * when the snapshot hasn't loaded or names no such agent, so the caller
+ * falls back to its own slug-derived label rather than rendering a blank.
+ */
+export function displayNameForAddress(
+ address: string,
+ displayNames: AgentDisplayNames | undefined,
+): string | undefined {
+ return displayNames?.get(address);
+}
diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts
index 33240b9f3d..0dff65cd8e 100644
--- a/packages/chat-ui/src/api.ts
+++ b/packages/chat-ui/src/api.ts
@@ -811,6 +811,7 @@ const WorkbenchAgentWire = type({
handle: "string",
definitionId: "string",
definitionAssetId: "string",
+ displayName: "string",
});
export type WorkbenchAgent = typeof WorkbenchAgentWire.infer;
diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts
index 08992f8c5c..2cff63154a 100644
--- a/packages/chat-ui/src/chat-workspace.test.ts
+++ b/packages/chat-ui/src/chat-workspace.test.ts
@@ -2,23 +2,23 @@ import { describe, expect, test } from "bun:test";
import {
bringInLoadErrorMessage,
- buildTeamAvatarStack,
+ buildMemberAvatarStack,
} from "./chat-workspace";
import type { ParticipantRecord } from "./api";
import { CHAT_STRINGS } from "./strings";
-describe("buildTeamAvatarStack (CL-6594)", () => {
+describe("buildMemberAvatarStack (CL-6594)", () => {
test("gives every agent participant its own initial and its own generated color, never a shared fallback", () => {
const participants: readonly ParticipantRecord[] = [
{ address: "run_myra@dana.localhost", handle: "myra" },
{ address: "run_scout@dana.localhost", handle: "scout" },
];
- const stack = buildTeamAvatarStack(participants, []);
+ const stack = buildMemberAvatarStack(participants);
expect(stack).toHaveLength(2);
expect(stack.map((entry) => entry.initials)).toEqual(["M", "S"]);
- expect(stack.map((entry) => entry.label)).toEqual(["myra", "scout"]);
+ expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Scout"]);
expect(stack.every((entry) => entry.tone === "agent")).toBe(true);
const [myra, scout] = stack;
@@ -31,26 +31,16 @@ describe("buildTeamAvatarStack (CL-6594)", () => {
expect(myra?.color).not.toBe(scout?.color);
});
- test("keeps every agent visible alongside live humans, agents first", () => {
+ test("is the static roster only — live presence is a separate stack", () => {
const participants: readonly ParticipantRecord[] = [
{ address: "run_myra@dana.localhost", handle: "myra" },
{ address: "run_scout@dana.localhost", handle: "scout" },
];
- const stack = buildTeamAvatarStack(participants, [
- {
- principalId: "prn_dana",
- displayName: "Dana",
- color: "hsl(10 70% 60%)",
- textColor: "#000000",
- },
- ]);
-
- expect(stack.map((entry) => entry.label)).toEqual([
- "myra",
- "scout",
- "Dana",
- ]);
+ const stack = buildMemberAvatarStack(participants);
+
+ expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Scout"]);
+ expect(stack.every((entry) => entry.tone === "agent")).toBe(true);
});
test("includes the signed-in human from the roster even with empty presence (CL-6779)", () => {
@@ -61,9 +51,9 @@ describe("buildTeamAvatarStack (CL-6594)", () => {
{ address: "prn_dana", handle: "Dana" },
];
- const stack = buildTeamAvatarStack(participants, []);
+ const stack = buildMemberAvatarStack(participants);
- expect(stack.map((entry) => entry.label)).toEqual(["myra", "Dana"]);
+ 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");
@@ -71,23 +61,32 @@ describe("buildTeamAvatarStack (CL-6594)", () => {
expect(human?.color).toBeDefined();
});
- test("dedupes a roster human who is also live in presence", () => {
+ test("prefers the signed-in display name over a raw participant handle", () => {
+ const participants: readonly ParticipantRecord[] = [
+ { address: "prn_self", handle: "ada-handle" },
+ ];
+
+ const stack = buildMemberAvatarStack(participants, undefined, {
+ principalId: "prn_self",
+ name: "Ada Lovelace",
+ });
+
+ expect(stack.map((entry) => entry.label)).toEqual(["Ada Lovelace"]);
+ expect(stack.map((entry) => entry.initials)).toEqual(["A"]);
+ });
+
+ test("prefers resolved agent display names over handle slugs (CL-6424)", () => {
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%)");
+ const stack = buildMemberAvatarStack(
+ participants,
+ new Map([["run_myra@dana.localhost", "Myra the Helper"]]),
+ );
+
+ expect(stack.map((entry) => entry.label)).toEqual(["Myra the Helper"]);
+ expect(stack.map((entry) => entry.initials)).toEqual(["M"]);
});
});
diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx
index 1a655c61bf..15beea8be9 100644
--- a/packages/chat-ui/src/chat-workspace.tsx
+++ b/packages/chat-ui/src/chat-workspace.tsx
@@ -78,10 +78,17 @@ import type { StreamingReplyState } from "./streaming-reply";
import {
AgentBadge,
WorkbenchTimeline,
+ displayNameFromHandle,
+ localPartOf,
messageDomId,
messageText,
} from "./timeline";
import type { FailedTurnRecovery } from "./timeline";
+import {
+ agentDisplayNamesFromAgents,
+ displayNameForAddress,
+ type AgentDisplayNames,
+} from "./agent-display-names";
import { NoUsableModelBanner } from "./no-usable-model-banner";
import { ResumeFailedBanner } from "./resume-failed-banner";
import type {
@@ -162,9 +169,9 @@ export interface PresenceMember {
readonly textColor: string;
}
-/** One entry in the header's combined who's-active stack — an agent
- * participant or a live human, normalized to the one shape the stack
- * renders regardless of source. */
+/** 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. */
export interface TeamAvatarEntry {
readonly key: string;
readonly initials: string;
@@ -175,92 +182,83 @@ export interface TeamAvatarEntry {
}
/** How many avatars the header shows before collapsing the rest into a
- * "+N" chip. */
+ * "+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 = {
+ readonly label: string;
+ readonly href?: string;
+};
+
+/** Chrome the host lifts into `StageTopBar` (`crumbs` / `subtitle` /
+ * `actions`) so `/w` does not keep a second identity row. */
+export type ChatHeaderChrome = {
+ readonly crumbs: readonly ChatHeaderCrumb[];
+ readonly subtitle?: ReactNode;
+ readonly actions?: ReactNode;
+};
+
+const WORKBENCHES_LIST_CHROME: ChatHeaderChrome = {
+ crumbs: [{ label: CHAT_STRINGS.chatsSectionLabel }],
+};
+
/**
- * 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 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.
+ * The workbench's static member stack: every agent participant plus every
+ * 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.
*
* Each agent gets its own `generatedAvatarStyle` fill keyed by address
- * (CL-6594) — the same deterministic-per-principal machinery humans
- * already use — rather than one shared CSS accent color for every
- * agent, so two agents in the same room never render as
- * indistinguishable avatars.
+ * (CL-6594). Human labels prefer `currentUser.name` when the roster entry
+ * is the signed-in reader — never a raw handle when a display name exists.
*/
-export function buildTeamAvatarStack(
+export function buildMemberAvatarStack(
participants: readonly ParticipantRecord[],
- presenceMembers: readonly PresenceMember[],
+ displayNames?: AgentDisplayNames,
+ currentUser?: CurrentUser,
): readonly TeamAvatarEntry[] {
const agents = participants
.filter((participant) => isAgentAddress(participant.address))
.map((participant) => {
const style = generatedAvatarStyle(participant.address);
+ const label =
+ displayNameForAddress(participant.address, displayNames) ??
+ displayNameFromHandle(participant.handle);
return {
key: participant.address,
- initials: participant.handle.slice(0, 1).toUpperCase(),
- label: participant.handle,
+ initials: label.slice(0, 1).toUpperCase(),
+ label,
tone: "agent" as const,
color: style["--avatar-identity-bg"],
textColor: style["--avatar-identity-fg"],
};
});
- 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) {
+ const humans = participants
+ .filter((participant) => !isAgentAddress(participant.address))
+ .map((participant) => {
+ const style = generatedAvatarStyle(participant.address);
+ const label = typingLabel(
+ localPartOf(participant.address),
+ participants,
+ currentUser,
+ );
return {
- key: live.principalId,
- initials: live.displayName.slice(0, 1).toUpperCase(),
- label: live.displayName,
+ key: participant.address,
+ initials: label.slice(0, 1).toUpperCase(),
+ label,
tone: "neutral" as const,
- color: live.color,
- textColor: live.textColor,
+ color: style["--avatar-identity-bg"],
+ textColor: style["--avatar-identity-fg"],
};
- }
- 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];
+ return [...agents, ...humans];
}
type WorkbenchesState =
@@ -599,6 +597,7 @@ function ChatWorkspaceInner({
connectGithubActions,
connectServiceActions,
headerLeading,
+ headerSlot,
registerComposerInsert,
listMembers,
onCreateRoutineInSpace,
@@ -668,8 +667,12 @@ function ChatWorkspaceInner({
readonly connectServiceActions?: ConnectServiceActions;
/** Host-supplied control rendered first in the workbench header — the
* shell's single col2 toggle, so chat carries the same top-bar chrome as
- * every other stage surface. */
+ * every other stage surface. Unused when `headerSlot` owns the bar. */
readonly headerLeading?: ReactNode;
+ /** Host-owned stage bar. When set, identity and primary actions render
+ * through this slot (`StageTopBar`'s crumbs / subtitle / actions) instead
+ * of `.chat-workbench-header`. */
+ readonly headerSlot?: (chrome: ChatHeaderChrome) => ReactNode;
/**
* Lets the host (command palette, canvas "insert into composer", …) push
* text into the live composer. Called with the insert fn when a composer
@@ -1201,6 +1204,14 @@ function ChatWorkspaceInner({
workbenchAgentsQuery.data?.length === 1
? workbenchAgentsQuery.data[0]?.definitionAssetId
: undefined;
+ // Person-facing display names for this workbench's agents (CL-6424),
+ // keyed by participant address. Memoized so `MessageParts`'s memo guard
+ // (CL-6625) keeps working: a fresh Map every render would read as new
+ // props on every row and re-render the whole timeline per token.
+ const agentDisplayNames: AgentDisplayNames = useMemo(
+ () => agentDisplayNamesFromAgents(workbenchAgentsQuery.data ?? []),
+ [workbenchAgentsQuery.data],
+ );
const failedTurnRecovery = useMemo((): FailedTurnRecovery => {
const definitionIdByAddress: Record = {};
for (const agent of workbenchAgentsQuery.data ?? []) {
@@ -1345,23 +1356,224 @@ function ChatWorkspaceInner({
[presenceRoster, activeWorkbench?.participants, currentUser],
);
- // Team stack: every active agent + live human for the top bar.
- const teamStack = buildTeamAvatarStack(
+ // Static member stack (square) vs live presence (round) — never one
+ // combined circular team stack.
+ const memberStack = buildMemberAvatarStack(
activeWorkbench?.participants ?? [],
- presenceMembers,
+ 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,
+ );
+ const presenceStackOverflow =
+ presenceMembers.length - visiblePresenceStack.length;
+
+ const showRoomChrome =
+ workbenchesState.kind === "ready" &&
+ activeWorkbenchId !== null &&
+ !workbenchGone &&
+ !awaitingWorkbenchEvidence;
+
+ const roomTitle =
+ activeWorkbenchDisplayTitle || CHAT_STRINGS.unnamedWorkbench;
+
+ const headerActions = (
+ <>
+ {depth1Threads.length > 0 ? (
+
+
+ {CHAT_STRINGS.threadsMenuCount(depth1Threads.length)}
+
+
+
;
}
+function withListHeader(
+ headerSlot: ((chrome: ChatHeaderChrome) => ReactNode) | undefined,
+ children: ReactNode,
+): ReactNode {
+ if (headerSlot === undefined) {
+ return {children};
+ }
+ return (
+ <>
+ {headerSlot(WORKBENCHES_LIST_CHROME)}
+ {children}
+ >
+ );
+}
+
export function ChatWorkspace({
tenant,
workbenchId = null,
@@ -1842,6 +1929,7 @@ export function ChatWorkspace({
connectGithubActions,
connectServiceActions,
headerLeading,
+ headerSlot,
registerComposerInsert,
listMembers,
onCreateRoutineInSpace,
@@ -1914,8 +2002,12 @@ export function ChatWorkspace({
readonly connectServiceActions?: ConnectServiceActions;
/** Host-supplied control rendered first in the workbench header — the
* shell's single col2 toggle, so chat carries the same top-bar chrome as
- * every other stage surface. */
+ * every other stage surface. Unused when `headerSlot` owns the bar. */
readonly headerLeading?: ReactNode;
+ /** Host-owned stage bar. When set, identity and primary actions render
+ * through this slot (`StageTopBar`'s crumbs / subtitle / actions) instead
+ * of `.chat-workbench-header`. */
+ readonly headerSlot?: (chrome: ChatHeaderChrome) => ReactNode;
/** See `ChatWorkspaceInner`'s prop of the same name. */
readonly registerComposerInsert?: (
insert: ((text: string) => void) | null,
@@ -1981,6 +2073,7 @@ export function ChatWorkspace({
: {})}
{...(onFixConnection !== undefined ? { onFixConnection } : {})}
{...(headerLeading !== undefined ? { headerLeading } : {})}
+ {...(headerSlot !== undefined ? { headerSlot } : {})}
{...(registerComposerInsert !== undefined
? { registerComposerInsert }
: {})}
@@ -2004,40 +2097,33 @@ export function ChatWorkspace({
/>
);
case "empty":
- return (
-
- }
- title="No workbench yet"
- description="Create or join a workbench before chatting."
- />
-
+ return withListHeader(
+ headerSlot,
+ }
+ title="No workbench yet"
+ description="Create or join a workbench before chatting."
+ />,
);
case "unauthenticated":
- return (
-
- }
- title="Sign in to continue"
- description="Your conversations live on a workbench — sign in to open them."
- />
-
+ return withListHeader(
+ headerSlot,
+ }
+ title="Sign in to continue"
+ description="Your conversations live on a workbench — sign in to open them."
+ />,
);
case "error":
- return (
-
- }
- title="Couldn't open this workbench"
- description={tenant.message}
- />
-
+ return withListHeader(
+ headerSlot,
+ }
+ title="Couldn't open this workbench"
+ description={tenant.message}
+ />,
);
case "loading":
- return (
-
-
-
- );
+ return withListHeader(headerSlot, );
}
}
diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx
index 653e52902f..e6ea463820 100644
--- a/packages/chat-ui/src/composer.tsx
+++ b/packages/chat-ui/src/composer.tsx
@@ -20,6 +20,7 @@ import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity";
import type { Part, ParticipantRecord } from "./api";
+import type { AgentDisplayNames } from "./agent-display-names";
import {
activeMentionQuery,
filterMentionOptions,
@@ -326,6 +327,9 @@ export const Composer = forwardRef<
* "Bring in…" group de-dupes against — omitted candidates are
* already in the workbench. Defaults to empty. */
readonly participants?: readonly ParticipantRecord[];
+ /** Resolved agent display names (CL-6424) — the popover rows show
+ * these, never raw handle slugs. */
+ readonly agentDisplayNames?: AgentDisplayNames;
/** Workspace members not yet in this workbench — the "Bring in…"
* group's person half. Defaults to empty (no group rendered). */
readonly members?: readonly BringInMember[];
@@ -371,6 +375,7 @@ export const Composer = forwardRef<
members = [],
invitableAgents = [],
bringInLoadError = null,
+ agentDisplayNames,
onSend,
onInviteAgent,
onOpenAgentsSettings,
@@ -481,7 +486,12 @@ export const Composer = forwardRef<
const mentionOptions: readonly MentionOption[] =
mention !== null
? filterMentionOptions(
- mentionOptionsFromWorkbench(participants, members, invitableAgents),
+ mentionOptionsFromWorkbench(
+ participants,
+ members,
+ invitableAgents,
+ agentDisplayNames,
+ ),
mention.query,
)
: [];
diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts
index 564bed2b59..5d8426e03e 100644
--- a/packages/chat-ui/src/index.ts
+++ b/packages/chat-ui/src/index.ts
@@ -1,5 +1,10 @@
export { ChatWorkspace } from "./chat-workspace";
-export type { TenantResolution, PresenceMember } from "./chat-workspace";
+export type {
+ TenantResolution,
+ PresenceMember,
+ ChatHeaderChrome,
+ ChatHeaderCrumb,
+} from "./chat-workspace";
export { WorkbenchTimeline, messageDomId, findRetryText } from "./timeline";
export { NoUsableModelBanner } from "./no-usable-model-banner";
@@ -65,6 +70,11 @@ export {
mentionOptionsFromWorkbench,
resolveBringInLists,
} from "./mentions";
+export {
+ agentDisplayNamesFromAgents,
+ displayNameForAddress,
+} from "./agent-display-names";
+export type { AgentDisplayNames } from "./agent-display-names";
export type {
BringInAgentDefinition,
BringInListFailure,
diff --git a/packages/chat-ui/src/invite-agent-dialog.tsx b/packages/chat-ui/src/invite-agent-dialog.tsx
index dd91c88fb8..66bc7120b6 100644
--- a/packages/chat-ui/src/invite-agent-dialog.tsx
+++ b/packages/chat-ui/src/invite-agent-dialog.tsx
@@ -21,7 +21,7 @@ import {
import { Users, WarningCircle } from "@corbits/icons";
import { useEffect, useState } from "react";
-import { humanizeSlug } from "@corbits/chat/display-name";
+import { deriveDisplayName } from "@corbits/chat/display-name";
import {
ChatApiError,
describeChatError,
@@ -153,14 +153,18 @@ export function InviteAgentDialog({
) : (
{state.items.map((definition) => {
- // `description` carries the definition's real display
- // name (set at creation), never a summary sentence — see
- // `InvitableDefinition`'s own doc comment — so the
- // fallback below is "no display name was ever set", not
- // "no description exists". `name` is the immutable slug
- // and never belongs in the title slot on its own.
- const displayName =
- definition.description ?? humanizeSlug(definition.name);
+ // One canonical rule for "what does this agent look like
+ // to a person" (CL-6424) — the description when one was
+ // set at creation, else a humanized reading of the
+ // immutable slug — never a scattered reimplementation
+ // that could drift (or leak an internal id as a fake
+ // name) beside `@corbits/chat/display-name`.
+ const displayName = deriveDisplayName({
+ name: definition.name,
+ ...(definition.description !== undefined
+ ? { description: definition.description }
+ : {}),
+ });
return (
isAgentAddress(participant.address))
.map((participant) => ({
id: participant.address,
handle: participant.handle,
- label: readableLabel(participant.handle),
+ label:
+ displayNameForAddress(participant.address, displayNames) ??
+ readableLabel(participant.handle),
}));
}
@@ -156,6 +165,7 @@ export function mentionOptionsFromWorkbench(
participants: readonly ParticipantRecord[],
members: readonly BringInMember[],
invitableAgents: readonly BringInAgentDefinition[],
+ displayNames?: AgentDisplayNames,
): readonly MentionOption[] {
const agentParticipants: MentionOption[] = participants
.filter((participant) => isAgentAddress(participant.address))
@@ -164,7 +174,9 @@ export function mentionOptionsFromWorkbench(
candidate: {
id: participant.address,
handle: participant.handle,
- label: readableLabel(participant.handle),
+ label:
+ displayNameForAddress(participant.address, displayNames) ??
+ readableLabel(participant.handle),
},
}));
const peopleParticipants: MentionOption[] = participants
diff --git a/packages/chat-ui/src/streaming-reply.test.ts b/packages/chat-ui/src/streaming-reply.test.ts
index 52222ae940..6bad4bd3c8 100644
--- a/packages/chat-ui/src/streaming-reply.test.ts
+++ b/packages/chat-ui/src/streaming-reply.test.ts
@@ -602,6 +602,17 @@ describe("typingAgentNames", () => {
expect(typingAgentNames(awaiting(""), [HUMAN])).toEqual([]);
});
+ test("a resolved display name wins over the slug-derived one (CL-6424)", () => {
+ expect(
+ typingAgentNames(
+ awaiting(""),
+ [HUMAN, MYRA],
+ undefined,
+ new Map([[MYRA.address, "Myra the Helper"]]),
+ ),
+ ).toEqual(["Myra the Helper"]);
+ });
+
test("names the mentioned agent, not the first agent on the workbench", () => {
const jimmy = { address: "jimmy@agents.example", handle: "jimmy" };
expect(
diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts
index 60ed1cea33..5912fbf87c 100644
--- a/packages/chat-ui/src/streaming-reply.ts
+++ b/packages/chat-ui/src/streaming-reply.ts
@@ -16,6 +16,10 @@ import { useEffect, useRef, useState } from "react";
import { isAgentAddress, mentionedParticipants } from "@corbits/chat/mentions";
import { reportError } from "@corbits/error-sink";
import type { Part, ParticipantRecord } from "./api";
+import {
+ displayNameForAddress,
+ type AgentDisplayNames,
+} from "./agent-display-names";
import { displayNameFromHandle } from "./timeline";
/**
@@ -465,6 +469,7 @@ export function typingAgentNames(
streamingReply: StreamingReplyState,
participants: readonly ParticipantRecord[],
parts?: readonly Part[],
+ displayNames?: AgentDisplayNames,
): readonly string[] {
if (!isPendingReply(streamingReply)) return [];
const agents = participants.filter((participant) =>
@@ -480,7 +485,11 @@ export function typingAgentNames(
: agents.length === 1
? agents
: [];
- return named.map((agent) => displayNameFromHandle(agent.handle));
+ return named.map(
+ (agent) =>
+ displayNameForAddress(agent.address, displayNames) ??
+ displayNameFromHandle(agent.handle),
+ );
}
/** The latest human (non-agent, non-streaming) message's parts — what
diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts
index b256ea3d70..84361b88ca 100644
--- a/packages/chat-ui/src/strings.ts
+++ b/packages/chat-ui/src/strings.ts
@@ -112,6 +112,7 @@ export const CHAT_STRINGS = {
inviteAgentAction: "Invite agent",
workbenchMembersLabel: "Members",
+ workbenchPresenceLabel: "Live",
teamStackOverflow: (count: number) =>
`${count} more ${count === 1 ? "member" : "members"}`,
threadsMenuCount: (count: number) =>
diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css
index fd720479f0..a03dd1c961 100644
--- a/packages/chat-ui/src/styles.css
+++ b/packages/chat-ui/src/styles.css
@@ -2931,6 +2931,7 @@
}
.chat-thread-breadcrumb-link {
+ flex-shrink: 0;
border: 0;
background: transparent;
padding: 0;
@@ -3058,42 +3059,35 @@
text-underline-offset: 2px;
}
-.chat-team-stack {
+.chat-member-stack {
display: flex;
align-items: center;
}
-.chat-team-stack-overflow {
+.chat-member-avatar,
+.chat-member-stack-overflow {
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
- border-radius: 50%;
+ border-radius: 0;
border: 1.5px solid var(--background);
margin-left: -0.35rem;
- background: var(--muted);
- color: var(--muted-foreground);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
-.chat-member-stack {
- display: flex;
- align-items: center;
-}
-
-.chat-member-avatar {
- display: inline-flex;
- margin-left: -0.35rem;
- border: 1.5px solid var(--background);
-}
-
.chat-member-avatar:first-child {
margin-left: 0;
}
+.chat-member-stack-overflow {
+ background: var(--muted);
+ color: var(--muted-foreground);
+}
+
/* Settings sits apart from the quiet New routine / Insights pair — a hairline
divider keeps it reading as the header's one persistent, always-available
control rather than a third peer in that group. */
@@ -3115,7 +3109,8 @@
margin-left: 0.35rem;
}
-.chat-presence-avatar {
+.chat-presence-avatar,
+.chat-presence-stack-overflow {
display: grid;
place-items: center;
width: 1.5rem;
@@ -3134,6 +3129,11 @@
margin-left: 0;
}
+.chat-presence-stack-overflow {
+ background: var(--muted);
+ color: var(--muted-foreground);
+}
+
/* Message body column: bubble/parts + reaction/pin row share one flex
column so chips sit under the bubble instead of as a loose sibling of
the whole header+bubble cluster (CL-6661). Each direct child is a
diff --git a/packages/chat-ui/src/timeline.test.ts b/packages/chat-ui/src/timeline.test.ts
index 0888a06c7c..59b8d55a8a 100644
--- a/packages/chat-ui/src/timeline.test.ts
+++ b/packages/chat-ui/src/timeline.test.ts
@@ -45,6 +45,19 @@ describe("friendlyEventText — workbench.agent-joined (CL-6594)", () => {
};
expect(friendlyEventText(part, [])).toBe("An agent joined");
});
+
+ test("prefers the resolved display name over the handle slug (CL-6424)", () => {
+ const participants: readonly ParticipantRecord[] = [
+ { address: "run_myra@dana.localhost", handle: "myra" },
+ ];
+ expect(
+ friendlyEventText(
+ agentJoinedPart("run_myra@dana.localhost"),
+ participants,
+ new Map([["run_myra@dana.localhost", "Myra the Helper"]]),
+ ),
+ ).toBe("Myra the Helper joined");
+ });
});
describe("friendlyEventText — connection.connected (CL-6741)", () => {
diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx
index 41b26a10ca..2f161db9d4 100644
--- a/packages/chat-ui/src/timeline.tsx
+++ b/packages/chat-ui/src/timeline.tsx
@@ -25,6 +25,10 @@ import {
toast,
} from "@corbits/react-ui";
import { toReactUiReasoning } from "./agent-part-adapter";
+import {
+ displayNameForAddress,
+ type AgentDisplayNames,
+} from "./agent-display-names";
import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity";
import { groupTimelineParts } from "./tool-activity";
import { ToolActivityGroup } from "./tool-activity-view";
@@ -289,6 +293,7 @@ function senderDisplay(
sender: MessageSender | undefined,
participants: readonly ParticipantRecord[],
currentUser: CurrentUser | undefined,
+ displayNames?: AgentDisplayNames,
): SenderDisplay | undefined {
if (sender === undefined) return undefined;
@@ -311,21 +316,27 @@ function senderDisplay(
if (matched !== undefined) {
const isAgent = isAgentAddress(matched.address);
const senderName = sender.name;
- const displayName =
+ const wireName =
senderName !== null && senderName.trim().length > 0
? senderName
: undefined;
- // An agent whose wire message carries no display name still gets a
- // human one derived from its handle ("myra" -> "Myra") — the raw
- // @handle is a wire identifier, kept to the tooltip only.
+ // An agent renders its resolved display name (CL-6424) — the wire
+ // name when the server sent one, else the `/agents` snapshot's — and
+ // only then falls back to a human reading of its handle ("myra" ->
+ // "Myra"). The raw @handle is a wire identifier, kept to the tooltip
+ // only.
+ const resolvedName =
+ isAgent === true
+ ? (displayNameForAddress(matched.address, displayNames) ?? wireName)
+ : wireName;
const label =
- displayName ??
+ resolvedName ??
(isAgent ? displayNameFromHandle(matched.handle) : matched.handle);
return {
label,
handle: matched.handle,
isAgent,
- initials: initialsOf(displayName ?? matched.handle),
+ initials: initialsOf(resolvedName ?? matched.handle),
id: matched.address,
};
}
@@ -437,12 +448,16 @@ function TextBubble({
pendingStatus,
pendingNonce,
pendingActions,
+ agentDisplayNames,
}: {
text: string;
createdAt: string;
sender: MessageSender | undefined;
participants: readonly ParticipantRecord[];
currentUser: CurrentUser | undefined;
+ /** Resolved agent display names (CL-6424) — the message header shows
+ * these, never the raw handle slug. */
+ readonly agentDisplayNames?: AgentDisplayNames;
onOpenProfile?: (subject: ProfileSubject) => void;
onFixConnection?: () => void;
/** `false` when this bubble continues an unbroken run of messages from
@@ -463,7 +478,12 @@ function TextBubble({
pendingActions?: PendingActions;
}) {
const consumerText = consumerFacingInferenceText(text);
- const display = senderDisplay(sender, participants, currentUser);
+ const display = senderDisplay(
+ sender,
+ participants,
+ currentUser,
+ agentDisplayNames,
+ );
const isOwn =
currentUser !== undefined &&
sender !== undefined &&
@@ -593,6 +613,7 @@ function TextBubble({
function joinedAgentName(
part: Part & { kind: "event" },
participants: readonly ParticipantRecord[],
+ displayNames?: AgentDisplayNames,
): string | undefined {
const data =
typeof part.data === "object" && part.data !== null
@@ -603,6 +624,8 @@ function joinedAgentName(
? data.address
: undefined;
if (address === undefined) return undefined;
+ const resolved = displayNameForAddress(address, displayNames);
+ if (resolved !== undefined) return resolved;
const handle =
participants.find((participant) => participant.address === address)
?.handle ?? localPartOf(address);
@@ -612,6 +635,7 @@ function joinedAgentName(
export function friendlyEventText(
part: Part & { kind: "event" },
participants: readonly ParticipantRecord[],
+ displayNames?: AgentDisplayNames,
): string {
const data =
typeof part.data === "object" && part.data !== null
@@ -619,7 +643,7 @@ export function friendlyEventText(
: undefined;
switch (part.event) {
case "workbench.agent-joined": {
- const name = joinedAgentName(part, participants);
+ const name = joinedAgentName(part, participants, displayNames);
return name !== undefined
? CHAT_STRINGS.eventAgentJoined(name)
: CHAT_STRINGS.eventAgentJoinedUnknown;
@@ -677,6 +701,7 @@ function EventLine({
createdAt,
participants,
collapsedText,
+ agentDisplayNames,
}: {
part: Part & { kind: "event" };
createdAt: string;
@@ -684,6 +709,7 @@ function EventLine({
/** The one line that stands in for a whole run of consecutive joins —
* see `collapseAgentJoinRuns`. Undefined on every other event row. */
collapsedText?: string;
+ readonly agentDisplayNames?: AgentDisplayNames;
}) {
const data =
typeof part.data === "object" && part.data !== null
@@ -711,7 +737,7 @@ function EventLine({
>
) : (
- friendlyEventText(part, participants)
+ friendlyEventText(part, participants, agentDisplayNames)
)}
{formatTimestamp(createdAt)}
@@ -766,8 +792,12 @@ function FailedTurnStrip({
failedTurnRecovery,
onRetryFailedTurn,
onWhatHappenedFailedTurn,
+ agentDisplayNames,
}: {
readonly item: TimelineMessageItem;
+ /** Resolved agent display names (CL-6424) — the strip names the agent
+ * whose turn failed, never its raw handle slug. */
+ readonly agentDisplayNames?: AgentDisplayNames;
/** The undelivered-turn notice's own text (`postUndeliveredNotice`,
* `@corbits/chat`) — already cause-aware server-side (a missing model
* credential reads "add or check your model key," a generic dispatch
@@ -790,7 +820,12 @@ function FailedTurnStrip({
) => void | Promise;
readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void;
}) {
- const display = senderDisplay(item.sender, participants, currentUser);
+ const display = senderDisplay(
+ item.sender,
+ participants,
+ currentUser,
+ agentDisplayNames,
+ );
const sender = display?.label ?? CHAT_STRINGS.senderFallbackMember;
const consumerDetail = consumerFacingInferenceText(detailText);
const [expanded, setExpanded] = useState(false);
@@ -914,12 +949,19 @@ function CancelledTurnStrip({
item,
participants,
currentUser,
+ agentDisplayNames,
}: {
readonly item: TimelineMessageItem;
readonly participants: readonly ParticipantRecord[];
readonly currentUser: CurrentUser | undefined;
+ readonly agentDisplayNames?: AgentDisplayNames;
}) {
- const display = senderDisplay(item.sender, participants, currentUser);
+ const display = senderDisplay(
+ item.sender,
+ participants,
+ currentUser,
+ agentDisplayNames,
+ );
const sender = display?.label ?? CHAT_STRINGS.senderFallbackMember;
return (