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)} + + +
+ {depth1Threads.map((thread) => ( +
+ + {(subThreadsByParentId.get(thread.id) ?? []).map( + (subThread) => ( + + ), + )} +
+ ))} +
+
+ ) : null} + {visibleMemberStack.length > 0 ? ( +
+ {visibleMemberStack.map((entry) => ( + + {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 ? ( + + ) : null} +
+ +
+ ); - const visibleTeamStack = teamStack.slice(0, TEAM_AVATAR_STACK_LIMIT); - const teamStackOverflow = teamStack.length - visibleTeamStack.length; + + const threadBreadcrumb = inThreadView ? ( + + ) : null; + + const roomChrome: ChatHeaderChrome = { + crumbs: [{ label: roomTitle }], + ...(inThreadView && threadBreadcrumb !== null + ? { subtitle: threadBreadcrumb } + : activeChatAgent !== undefined + ? { subtitle: } + : {}), + actions: headerActions, + }; // The workbench header only exists once a workbench is active; the loading, // error, and no-workbench states still carry the host's leading control (the - // shell's col2 toggle) so the sidebar stays reachable. + // shell's col2 toggle) so the sidebar stays reachable. When `headerSlot` + // owns the bar, that chrome lifts out of `.chat-workbench-header`. const bareLeadingHeader = + headerSlot === undefined && headerLeading !== undefined && - (workbenchesState.kind !== "ready" || activeWorkbenchId === null) ? ( + !showRoomChrome ? (
{headerLeading}
) : null; + const stageHeader = + headerSlot !== undefined + ? headerSlot(showRoomChrome ? roomChrome : WORKBENCHES_LIST_CHROME) + : bareLeadingHeader; + if ( settingsOpen && activeWorkbenchId !== null && @@ -1412,7 +1624,7 @@ function ChatWorkspaceInner({ <>
- {bareLeadingHeader} + {stageHeader} {workbenchesState.kind === "loading" ? ( ) : workbenchesState.kind === "error" ? ( @@ -1461,164 +1673,20 @@ function ChatWorkspaceInner({ ) : ( <> -
- {headerLeading} - {inThreadView ? ( - - ) : ( -
-

- {activeWorkbenchDisplayTitle || - CHAT_STRINGS.unnamedWorkbench} -

- {activeChatAgent !== undefined ? : null} -
- )} -
- {depth1Threads.length > 0 ? ( -
- - {CHAT_STRINGS.threadsMenuCount(depth1Threads.length)} - - -
- {depth1Threads.map((thread) => ( -
- - {(subThreadsByParentId.get(thread.id) ?? []).map( - (subThread) => ( - - ), - )} -
- ))} -
-
- ) : null} - {visibleTeamStack.length > 0 ? ( -
- {visibleTeamStack.map((entry) => ( - - {entry.initials} - - ))} - {teamStackOverflow > 0 ? ( - - +{teamStackOverflow} - - ) : null} + {headerSlot === undefined ? ( +
+ {headerLeading} + {inThreadView ? ( + threadBreadcrumb + ) : ( +
+

{roomTitle}

+ {activeChatAgent !== undefined ? : null}
- ) : null} - {offerInviteControl ? ( - - ) : null} -
- -
+ )} +
{headerActions}
-
+ ) : null} {messagesState.kind === "loading" ? ( ) : messagesState.kind === "error" ? ( @@ -1685,6 +1753,7 @@ function ChatWorkspaceInner({ )} participants={activeWorkbench?.participants ?? []} {...(currentUser !== undefined ? { currentUser } : {})} + agentDisplayNames={agentDisplayNames} threadMetaByMessageId={threadMetaByMessageId} threadAffordanceMode={inThreadView ? "fork" : "reply"} onOpenThread={ @@ -1747,6 +1816,7 @@ function ChatWorkspaceInner({ streamingReply, activeWorkbench?.participants ?? [], addressedMessageParts, + agentDisplayNames, )} /> ) @@ -1769,8 +1839,10 @@ function ChatWorkspaceInner({ ref={composerRef} agents={mentionCandidatesFromParticipants( activeWorkbench?.participants ?? [], + agentDisplayNames, )} participants={activeWorkbench?.participants ?? []} + agentDisplayNames={agentDisplayNames} members={bringInLists.members} invitableAgents={bringInLists.invitableAgents} bringInLoadError={bringInLoadError} @@ -1822,6 +1894,21 @@ function ChatWorkspaceFrame({ children }: { readonly children: ReactNode }) { return
{children}
; } +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 (
    @@ -1124,6 +1166,7 @@ export function isSystemSenderItem(item: MessageItem): boolean { function agentJoinName( item: TimelineMessageItem, participants: readonly ParticipantRecord[], + displayNames?: AgentDisplayNames, ): string | undefined { if (item.parts.length !== 1) return undefined; const part = item.parts[0]; @@ -1134,7 +1177,7 @@ function agentJoinName( ) { return undefined; } - return joinedAgentName(part, participants); + return joinedAgentName(part, participants, displayNames); } /** @@ -1150,6 +1193,7 @@ function agentJoinName( export function collapseAgentJoinRuns( items: readonly TimelineMessageItem[], participants: readonly ParticipantRecord[], + displayNames?: AgentDisplayNames, ): { readonly textByLeadId: ReadonlyMap; readonly absorbedIds: ReadonlySet; @@ -1163,7 +1207,7 @@ export function collapseAgentJoinRuns( while (end < items.length) { const item = items[end]; if (item === undefined) break; - const name = agentJoinName(item, participants); + const name = agentJoinName(item, participants, displayNames); if (name === undefined) break; names.push(name); end += 1; @@ -1237,14 +1281,21 @@ function StreamingMessageGroup({ participants, currentUser, showDayDivider, + agentDisplayNames, }: { readonly item: TimelineMessageItem; readonly participants: readonly ParticipantRecord[]; readonly currentUser: CurrentUser | undefined; readonly showDayDivider: boolean; + readonly agentDisplayNames?: AgentDisplayNames; }) { const text = messageText(item); - const display = senderDisplay(item.sender, participants, currentUser); + const display = senderDisplay( + item.sender, + participants, + currentUser, + agentDisplayNames, + ); return (
    @@ -1600,6 +1651,7 @@ function MessagePartsInner({ failedTurnRecovery, onRetryFailedTurn, onWhatHappenedFailedTurn, + agentDisplayNames, }: { readonly item: TimelineMessageItem; /** The full timeline, oldest→newest — only read to recover the request @@ -1655,6 +1707,9 @@ function MessagePartsInner({ ) => void | Promise; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; readonly failedTurnRecovery?: FailedTurnRecovery; + /** Resolved agent display names (CL-6424) — headers, strips, and event + * lines show these, never raw handle slugs. */ + readonly agentDisplayNames?: AgentDisplayNames; }) { // A message this reader's own composer submitted and the server hasn't // issued an id for yet (see `TimelineMessageItem.pendingStatus`) offers @@ -1734,6 +1789,9 @@ function MessagePartsInner({ part.turnFailedReason === "tools_unsupported" ? { namedRecovery: part.turnFailedReason } : {})} + {...(agentDisplayNames !== undefined + ? { agentDisplayNames } + : {})} {...(retryText !== undefined ? { retryText } : {})} {...(failedTurnRecovery !== undefined ? { failedTurnRecovery } @@ -1754,6 +1812,9 @@ function MessagePartsInner({ item={item} participants={participants} currentUser={currentUser} + {...(agentDisplayNames !== undefined + ? { agentDisplayNames } + : {})} /> ); } @@ -1767,6 +1828,9 @@ function MessagePartsInner({ participants={participants} currentUser={currentUser} showHeader={showHeader} + {...(agentDisplayNames !== undefined + ? { agentDisplayNames } + : {})} {...(item.pendingStatus !== undefined ? { pendingStatus: item.pendingStatus, pendingNonce } : {})} @@ -1788,6 +1852,9 @@ function MessagePartsInner({ {...(collapsedJoinText !== undefined ? { collapsedText: collapsedJoinText } : {})} + {...(agentDisplayNames !== undefined + ? { agentDisplayNames } + : {})} /> ); } @@ -1880,6 +1947,7 @@ function MessagePartsInner({ mode={threadAffordanceMode} participants={participants} onOpen={() => onOpenThread(item.id)} + {...(agentDisplayNames !== undefined ? { agentDisplayNames } : {})} /> ) : null} {offersSocialChrome ? ( @@ -1948,17 +2016,20 @@ function ThreadAffordance({ mode, participants, onOpen, + agentDisplayNames, }: { readonly messageId: string; readonly meta: ThreadAffordanceMeta | undefined; readonly mode: ThreadAffordanceMode; readonly participants: readonly ParticipantRecord[]; readonly onOpen: () => void; + readonly agentDisplayNames?: AgentDisplayNames; }) { const replyCount = meta?.replyCount ?? 0; const addresses = meta?.participantAddresses ?? []; const initials = addresses.slice(0, 3).map((address) => { const handle = + displayNameForAddress(address, agentDisplayNames) ?? participants.find((p) => p.address === address)?.handle ?? address.slice(0, 1); return initialsOf(handle); @@ -2035,6 +2106,7 @@ export function WorkbenchTimeline({ scrollRestore, onScrollSnapshot, footer, + agentDisplayNames, }: { /** Server-issued messages, oldest→newest, plus any optimistic entries * the host is still resolving — see `TimelineMessageItem`'s @@ -2118,6 +2190,11 @@ export function WorkbenchTimeline({ /** Incoming-slot pulse (typing / pending-reply) rendered after the last * message so it sits where the next agent reply will land. */ readonly footer?: ReactNode; + /** Resolved agent display names (CL-6424) — headers, join lines, and + * empty states show these, never raw handle slugs. Undefined keeps the + * slug-derived fallback, so a host that never resolves names renders + * exactly what it always did. */ + readonly agentDisplayNames?: AgentDisplayNames; }) { const containerRef = useRef(null); // Starts pinned (true) unless a restored snapshot says otherwise — a @@ -2187,7 +2264,11 @@ export function WorkbenchTimeline({ return () => observer.disconnect(); }, []); - const joinRuns = collapseAgentJoinRuns(items, participants); + const joinRuns = collapseAgentJoinRuns( + items, + participants, + agentDisplayNames, + ); if (items.length === 0) { // A freshly created agent chat answers before it finishes setting up @@ -2211,11 +2292,14 @@ export function WorkbenchTimeline({ isAgentAddress(participant.address), ); if (readyAgent !== undefined) { + const readyAgentName = + displayNameForAddress(readyAgent.address, agentDisplayNames) ?? + displayNameFromHandle(readyAgent.handle); return (
    } - title={`Say hello to ${displayNameFromHandle(readyAgent.handle)}`} + title={`Say hello to ${readyAgentName}`} description={CHAT_STRINGS.emptyAgentTimelineDescription} />
    @@ -2259,6 +2343,9 @@ export function WorkbenchTimeline({ participants={participants} currentUser={currentUser} showDayDivider={showDayDivider} + {...(agentDisplayNames !== undefined + ? { agentDisplayNames } + : {})} /> ); } @@ -2277,6 +2364,7 @@ export function WorkbenchTimeline({ currentUser={currentUser} showDayDivider={showDayDivider} showHeader={showHeader} + {...(agentDisplayNames !== undefined ? { agentDisplayNames } : {})} {...(collapsedJoinText !== undefined ? { collapsedJoinText } : {})} threadMeta={threadMetaByMessageId?.get(item.id)} threadAffordanceMode={threadAffordanceMode} diff --git a/packages/chat-ui/test/agent-display-names.test.ts b/packages/chat-ui/test/agent-display-names.test.ts new file mode 100644 index 0000000000..e2ebedaffa --- /dev/null +++ b/packages/chat-ui/test/agent-display-names.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; + +import { + agentDisplayNamesFromAgents, + displayNameForAddress, +} from "../src/agent-display-names"; + +describe("agentDisplayNamesFromAgents", () => { + test("keys each agent's display name by its participant address", () => { + const names = agentDisplayNamesFromAgents([ + { address: "ins_echo@acme.example", displayName: "Myra" }, + { address: "ins_review@acme.example", displayName: "Reviewer" }, + ]); + expect(names.get("ins_echo@acme.example")).toBe("Myra"); + expect(names.get("ins_review@acme.example")).toBe("Reviewer"); + }); + + test("an empty snapshot builds an empty lookup", () => { + expect(agentDisplayNamesFromAgents([]).size).toBe(0); + }); +}); + +describe("displayNameForAddress", () => { + test("returns the known display name, never the handle slug", () => { + const names = agentDisplayNamesFromAgents([ + { address: "ins_echo@acme.example", displayName: "Myra" }, + ]); + expect(displayNameForAddress("ins_echo@acme.example", names)).toBe("Myra"); + }); + + test("is undefined for an unknown address so the caller falls back", () => { + const names = agentDisplayNamesFromAgents([ + { address: "ins_echo@acme.example", displayName: "Myra" }, + ]); + expect( + displayNameForAddress("ins_other@acme.example", names), + ).toBeUndefined(); + }); + + test("is undefined without a snapshot so the caller falls back", () => { + expect( + displayNameForAddress("ins_echo@acme.example", undefined), + ).toBeUndefined(); + }); +}); diff --git a/packages/chat-ui/test/agents-section.test.tsx b/packages/chat-ui/test/agents-section.test.tsx index d5fc8c6ff2..07dd2bf516 100644 --- a/packages/chat-ui/test/agents-section.test.tsx +++ b/packages/chat-ui/test/agents-section.test.tsx @@ -190,6 +190,7 @@ function stubFetch(options: { handle: agent.handle, definitionId: agent.definitionId, definitionAssetId: `ast_${agent.definitionId}`, + displayName: agent.name, })), }); } diff --git a/packages/chat-ui/test/api.test.ts b/packages/chat-ui/test/api.test.ts index 0685467cd6..326deff5f4 100644 --- a/packages/chat-ui/test/api.test.ts +++ b/packages/chat-ui/test/api.test.ts @@ -19,6 +19,7 @@ import { listInvitableDefinitions, listTenantInvitableDefinitions, listVisibleAgentDefinitions, + listWorkbenchAgents, openAgentDm, listMessages, listPinnedMessages, @@ -411,6 +412,55 @@ describe("listInvitableDefinitions", () => { }); }); +describe("listWorkbenchAgents", () => { + test("parses each agent with its person-facing display name (CL-6424)", async () => { + const calls = stubFetch(() => + json({ + items: [ + { + address: "ins_echo@acme.example", + handle: "myra", + definitionId: "wfd_echo", + definitionAssetId: "ast_wfd_echo", + displayName: "Myra", + }, + ], + }), + ); + const items = await listWorkbenchAgents("tenant_1", "chan_1"); + expect(calls[0]?.path).toBe( + "/api/tenants/tenant_1/chat/workbenches/chan_1/agents", + ); + expect(items).toEqual([ + { + address: "ins_echo@acme.example", + handle: "myra", + definitionId: "wfd_echo", + definitionAssetId: "ast_wfd_echo", + displayName: "Myra", + }, + ]); + }); + + test("throws a ChatApiError when an agent carries no display name", async () => { + stubFetch(() => + json({ + items: [ + { + address: "ins_echo@acme.example", + handle: "myra", + definitionId: "wfd_echo", + definitionAssetId: "ast_wfd_echo", + }, + ], + }), + ); + await expect( + listWorkbenchAgents("tenant_1", "chan_1"), + ).rejects.toBeInstanceOf(ChatApiError); + }); +}); + describe("listVisibleAgentDefinitions", () => { test("fetches the tenant's visible agent definitions, own and inherited", async () => { const calls = stubFetch(() => diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 5a411ef8b4..2d7cf2e7bf 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -55,6 +55,7 @@ type WorkbenchAgentFixture = { readonly handle: string; readonly definitionId: string; readonly definitionAssetId: string; + readonly displayName: string; }; function stubFetch( @@ -977,12 +978,14 @@ describe("composer slash commands — each wired command's real action", () => { handle: "echo", definitionId: "def_echo", definitionAssetId: "asset_echo", + displayName: "Echo", }, { address: "agent:asset_digest/ins_2", handle: "digest", definitionId: "def_digest", definitionAssetId: "asset_digest", + displayName: "Digest", }, ], }); @@ -1015,6 +1018,7 @@ describe("composer slash commands — each wired command's real action", () => { handle: "echo", definitionId: "def_echo", definitionAssetId: "asset_echo", + displayName: "Echo", }, ], }); @@ -2003,7 +2007,7 @@ describe("Workbench header polish (CL-6106)", () => { harness.unmount(); }); - test("agent participant chips share the roster's circular avatar, keeping the handle as a hover tooltip", async () => { + test("agent participant chips live in the square member stack, naming the agent by display name (CL-6424 supersedes the raw-handle tooltip)", async () => { stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, @@ -2012,10 +2016,10 @@ describe("Workbench header polish (CL-6106)", () => { await harness.settle(); const chip = harness.container.querySelector( - '.chat-presence-avatar[data-agent="true"]', + '.chat-member-avatar[data-agent="true"]', ); expect(chip).not.toBeNull(); - expect((chip as HTMLElement).title).toBe("researcher"); + expect((chip as HTMLElement).title).toBe("Researcher"); expect((chip as HTMLElement).textContent).toBe("R"); harness.unmount(); }); @@ -2039,6 +2043,111 @@ describe("Workbench header polish (CL-6106)", () => { expect(actions?.lastElementChild?.contains(button)).toBe(true); harness.unmount(); }); + + test("headerSlot lifts identity and actions out of the in-stage header", async () => { + stubFetch(); + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + headerSlot: (chrome) => + createElement( + "div", + { "data-testid": "host-stage-bar" }, + chrome.crumbs.map((crumb) => crumb.label).join(" / "), + chrome.actions, + ), + }); + await harness.settle(); + + expect( + harness.container.querySelector(".chat-workbench-header"), + ).toBeNull(); + const hostBar = harness.container.querySelector( + '[data-testid="host-stage-bar"]', + ); + expect(hostBar).not.toBeNull(); + expect(hostBar?.textContent).toContain("Launch Planning"); + expect( + harness.container.querySelector('button[aria-label="Settings"]'), + ).not.toBeNull(); + harness.unmount(); + }); + + test("headerSlot thread view has one aria-current=page and a clickable close-thread", async () => { + stubThreadedFetch(); + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + headerSlot: (chrome) => + createElement( + "div", + { "data-testid": "host-stage-bar" }, + createElement( + "span", + { "aria-current": "page" }, + chrome.crumbs.at(-1)?.label, + ), + chrome.subtitle !== undefined + ? createElement( + "div", + { className: "stage-top-bar-sub" }, + chrome.subtitle, + ) + : null, + chrome.actions, + ), + }); + await harness.settle(); + + const openButton = harness.container.querySelector( + ".chat-thread-open", + ) as HTMLButtonElement; + await act(async () => { + openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await sleep(30); + }); + + expect( + harness.container.querySelectorAll('[aria-current="page"]'), + ).toHaveLength(1); + expect( + harness.container + .querySelector(".chat-thread-breadcrumb-current") + ?.getAttribute("aria-current"), + ).toBeNull(); + + const closeThread = harness.container.querySelector( + ".chat-thread-breadcrumb-link", + ) as HTMLButtonElement; + expect(closeThread).not.toBeNull(); + await act(async () => { + closeThread.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await sleep(30); + }); + expect( + harness.container.querySelector(".chat-thread-breadcrumb"), + ).toBeNull(); + harness.unmount(); + }); + + test("headerSlot still titles the stage while the tenant is loading", async () => { + const harness = await mount({ + tenant: { kind: "loading" }, + headerSlot: (chrome) => + createElement( + "div", + { "data-testid": "host-stage-bar" }, + chrome.crumbs[0]?.label, + ), + }); + await harness.settle(); + + expect( + harness.container.querySelector('[data-testid="host-stage-bar"]') + ?.textContent, + ).toBe("Workbenches"); + harness.unmount(); + }); }); describe("CL-6833: running-turn resume failure is visible, never silent idle", () => { diff --git a/packages/chat-ui/test/invite-agent-dialog.test.tsx b/packages/chat-ui/test/invite-agent-dialog.test.tsx index 745adb3e3e..8bc8dd2f5f 100644 --- a/packages/chat-ui/test/invite-agent-dialog.test.tsx +++ b/packages/chat-ui/test/invite-agent-dialog.test.tsx @@ -34,7 +34,11 @@ function jsonResponse(body: unknown, status = 200): Response { } function stubFetch(routes: { - invitable: () => readonly { id: string; name: string }[]; + invitable: () => readonly { + id: string; + name: string; + description?: string; + }[]; createJimmy?: () => { id: string }; }) { globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { @@ -52,7 +56,11 @@ function stubFetch(routes: { } async function mount(props: { - readonly invitable: () => readonly { id: string; name: string }[]; + readonly invitable: () => readonly { + id: string; + name: string; + description?: string; + }[]; readonly onInvite: (definitionId: string) => Promise; readonly onOpenChange: (open: boolean) => void; }) { @@ -146,3 +154,23 @@ describe("InviteAgentDialog's Jimmy quick-create row", () => { expect(closed).toBe(true); }); }); + +describe("InviteAgentDialog definition rows (CL-6424)", () => { + test("shows each definition's display name, never its raw slug", async () => { + const el = await mount({ + invitable: () => [ + { id: "wfd_myra", name: "myra", description: "Myra" }, + { id: "wfd_review", name: "code-review" }, + { id: "wfd_jimmy", name: JIMMY_QUICK_CREATE.handle }, + ], + onInvite: async () => undefined, + onOpenChange: () => undefined, + }); + const names = Array.from( + el.querySelectorAll( + '[data-testid="invitable-definition"] .chat-invitable-item-name', + ), + ).map((name) => name.textContent); + expect(names).toEqual(["Myra", "Code Review", "Jimmy"]); + }); +}); diff --git a/packages/chat-ui/test/mentions.test.ts b/packages/chat-ui/test/mentions.test.ts index 5119e647d6..b9ad7519ae 100644 --- a/packages/chat-ui/test/mentions.test.ts +++ b/packages/chat-ui/test/mentions.test.ts @@ -72,6 +72,25 @@ describe("mentionCandidatesFromParticipants", () => { ]), ).toEqual([]); }); + + test("prefers the resolved display name over the slug-derived label (CL-6424)", () => { + expect( + mentionCandidatesFromParticipants( + [ + { address: "ins_echo@agents.example", handle: "echo" }, + { address: "ins_review@agents.example", handle: "code-review" }, + ], + new Map([["ins_echo@agents.example", "Myra"]]), + ), + ).toEqual([ + { id: "ins_echo@agents.example", handle: "echo", label: "Myra" }, + { + id: "ins_review@agents.example", + handle: "code-review", + label: "Code Review", + }, + ]); + }); }); describe("filterMentionCandidates", () => { @@ -178,6 +197,19 @@ describe("mentionOptionsFromWorkbench and filterMentionOptions (CL-5879 mention- "reed", ]); }); + + test("in-workbench agent rows show the resolved display name (CL-6424)", () => { + const options = mentionOptionsFromWorkbench( + [{ address: "researcher@agents.example", handle: "researcher" }], + [], + [], + new Map([["researcher@agents.example", "Myra"]]), + ); + expect(options.map((option) => option.candidate.label)).toEqual(["Myra"]); + expect(options.map((option) => option.candidate.handle)).toEqual([ + "researcher", + ]); + }); }); describe("insertMention", () => { diff --git a/packages/chat-ui/test/presence-stack.test.tsx b/packages/chat-ui/test/presence-stack.test.tsx index 3758bddfc1..23645ba771 100644 --- a/packages/chat-ui/test/presence-stack.test.tsx +++ b/packages/chat-ui/test/presence-stack.test.tsx @@ -82,7 +82,8 @@ function stubFetch() { }) as typeof fetch; } -const { ChatWorkspace } = await import("../src/chat-workspace"); +const { ChatWorkspace, TEAM_AVATAR_STACK_LIMIT } = + await import("../src/chat-workspace"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -148,6 +149,10 @@ describe("workbench header presence stack", () => { const avatars = harness.container.querySelectorAll(".chat-presence-avatar"); expect(avatars).toHaveLength(2); + expect( + harness.container.querySelector(".chat-presence-stack"), + ).not.toBeNull(); + expect(harness.container.querySelector(".chat-member-stack")).toBeNull(); harness.unmount(); }); @@ -184,4 +189,34 @@ describe("workbench header presence stack", () => { ).toHaveLength(0); harness.unmount(); }); + + test("collapses anything past the member-stack limit into a +N chip", async () => { + stubFetch(); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + + const liveCount = TEAM_AVATAR_STACK_LIMIT + 4; + act(() => { + firstStream().emit("chat.presence.snapshot", { + members: Array.from({ length: liveCount }, (_, index) => ({ + principalId: `prn_${String(index)}`, + lastActiveAt: "2026-01-01T00:00:00Z", + })), + }); + }); + await harness.settle(); + + expect( + harness.container.querySelectorAll(".chat-presence-avatar"), + ).toHaveLength(TEAM_AVATAR_STACK_LIMIT); + const overflow = harness.container.querySelector( + ".chat-presence-stack-overflow", + ); + expect(overflow).not.toBeNull(); + expect(overflow?.textContent).toBe("+4"); + harness.unmount(); + }); }); diff --git a/packages/chat-ui/test/team-avatar-stack.test.tsx b/packages/chat-ui/test/team-avatar-stack.test.tsx index 153a10d559..e9d0c42cb0 100644 --- a/packages/chat-ui/test/team-avatar-stack.test.tsx +++ b/packages/chat-ui/test/team-avatar-stack.test.tsx @@ -1,8 +1,7 @@ -// The workbench header's combined who's-active stack: every agent -// participant on the workbench plus every human currently reflected in live -// presence (CL-6328: the workbench's own `chat.presence.snapshot` stream -// event, not a host-supplied prop), overlapping with a title tooltip, -// collapsing anything past TEAM_AVATAR_STACK_LIMIT into a "+N" chip. +// The workbench header's static member stack: every agent participant on +// the workbench plus every human on the roster, square avatars, collapsing +// anything past TEAM_AVATAR_STACK_LIMIT into a "+N" chip. Live presence is +// a separate round stack (see presence-stack.test.tsx). // Mirrors presence-stack.test.tsx's stub-fetch/mount harness. import { afterEach, describe, expect, test } from "bun:test"; @@ -51,6 +50,7 @@ afterEach(() => { function stubFetch(workbenchWire: { participants: { address: string; handle: string }[]; + agents?: { address: string; handle: string; displayName: string }[]; }) { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -60,6 +60,17 @@ function stubFetch(workbenchWire: { status: 200, headers: { "content-type": "application/json" }, }); + if (/\/chat\/workbenches\/[^/]+\/agents$/.test(path)) { + return json({ + items: (workbenchWire.agents ?? []).map((agent, index) => ({ + address: agent.address, + handle: agent.handle, + definitionId: `wfd_${String(index)}`, + definitionAssetId: `ast_wfd_${String(index)}`, + displayName: agent.displayName, + })), + }); + } if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { return json({ items: [ @@ -128,8 +139,8 @@ function humanParticipant(principalId: string, handle: string) { return { address: principalId, handle }; } -describe("workbench header team avatar stack", () => { - test("renders every agent participant and every live human", async () => { +describe("workbench header member avatar stack", () => { + test("renders every agent participant and every roster human in the square member stack", async () => { stubFetch({ participants: [ { address: "myra@agents.example", handle: "Myra" }, @@ -150,22 +161,30 @@ describe("workbench header team avatar stack", () => { }); await harness.settle(); - const stack = harness.container.querySelector(".chat-team-stack"); - expect(stack).not.toBeNull(); + const memberStack = harness.container.querySelector(".chat-member-stack"); + expect(memberStack).not.toBeNull(); + expect(harness.container.querySelector(".chat-team-stack")).toBeNull(); const agentAvatars = harness.container.querySelectorAll( - '.chat-presence-avatar[data-agent="true"]', + '.chat-member-avatar[data-agent="true"]', ); expect(agentAvatars).toHaveLength(1); const agentAvatar = agentAvatars[0] as HTMLElement; expect(agentAvatar.title).toBe("Myra"); expect(agentAvatar.textContent).toBe("M"); - const presenceAvatars = harness.container.querySelectorAll( - ".chat-presence-avatar:not([data-agent])", + const memberHumans = harness.container.querySelectorAll( + ".chat-member-avatar:not([data-agent])", ); - expect(presenceAvatars).toHaveLength(1); - expect((presenceAvatars[0] as HTMLElement).title).toBe("Alice"); + expect(memberHumans).toHaveLength(1); + expect((memberHumans[0] as HTMLElement).title).toBe("Alice"); + const liveStack = harness.container.querySelector(".chat-presence-stack"); + expect(liveStack).not.toBeNull(); + const liveAvatars = harness.container.querySelectorAll( + ".chat-presence-avatar", + ); + expect(liveAvatars).toHaveLength(1); + expect((liveAvatars[0] as HTMLElement).title).toBe("Alice"); expect( - harness.container.querySelector(".chat-team-stack-overflow"), + harness.container.querySelector(".chat-member-stack-overflow"), ).toBeNull(); harness.unmount(); }); @@ -185,7 +204,7 @@ describe("workbench header team avatar stack", () => { const agentAvatars = Array.from( harness.container.querySelectorAll( - '.chat-presence-avatar[data-agent="true"]', + '.chat-member-avatar[data-agent="true"]', ), ) as HTMLElement[]; expect(agentAvatars).toHaveLength(2); @@ -197,7 +216,7 @@ describe("workbench header team avatar stack", () => { // inline text color (the CSS custom-property indirection this // package uses elsewhere doesn't apply to this chip), proving two // agents never render with the exact same computed fill — the - // per-address color itself is `buildTeamAvatarStack`'s own unit + // per-address color itself is `buildMemberAvatarStack`'s own unit // test (`../src/chat-workspace.test.ts`), since happy-dom drops the // space-syntax `hsl()` `colorForPrincipal` emits before it reaches // any DOM assertion here. @@ -209,6 +228,31 @@ describe("workbench header team avatar stack", () => { harness.unmount(); }); + test("shows the resolved agent display name, never the raw handle slug (CL-6424)", async () => { + stubFetch({ + participants: [{ address: "myra@agents.example", handle: "myra" }], + agents: [ + { + address: "myra@agents.example", + handle: "myra", + displayName: "Myra the Helper", + }, + ], + }); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + + const agentAvatars = harness.container.querySelectorAll( + '.chat-member-avatar[data-agent="true"]', + ); + expect(agentAvatars).toHaveLength(1); + expect((agentAvatars[0] as HTMLElement).title).toBe("Myra the Helper"); + harness.unmount(); + }); + test("collapses anything past the limit into a +N chip", async () => { const humanNames = ["Alice", "Bob", "Carla", "Dana", "Eve", "Finn"]; const humanParticipants = humanNames.map((name, index) => @@ -237,7 +281,7 @@ describe("workbench header team avatar stack", () => { // 1 agent + 6 humans = 7 total, limit is 6, so one overflows. const overflow = harness.container.querySelector( - ".chat-team-stack-overflow", + ".chat-member-stack-overflow", ); expect(overflow).not.toBeNull(); expect(overflow?.textContent).toBe("+1"); @@ -267,7 +311,7 @@ describe("workbench header team avatar stack", () => { await harness.settle(); const presenceAvatars = harness.container.querySelectorAll( - ".chat-presence-avatar:not([data-agent])", + ".chat-presence-avatar", ); expect(presenceAvatars).toHaveLength(1); expect((presenceAvatars[0] as HTMLElement).title).toBe("sawyer"); @@ -275,4 +319,28 @@ describe("workbench header team avatar stack", () => { expect(presenceAvatars[0]?.textContent).toBe("S"); harness.unmount(); }); + + test("own member avatar uses currentUser.name, never the raw handle", async () => { + stubFetch({ + participants: [ + { address: "myra@agents.example", handle: "Myra" }, + humanParticipant("prn_self", "ada-handle"), + ], + }); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + currentUser: { principalId: "prn_self", name: "sawyer" }, + }); + await harness.settle(); + + const memberHumans = harness.container.querySelectorAll( + ".chat-member-avatar:not([data-agent])", + ); + expect(memberHumans).toHaveLength(1); + expect((memberHumans[0] as HTMLElement).title).toBe("sawyer"); + expect((memberHumans[0] as HTMLElement).title).not.toBe("ada-handle"); + expect(memberHumans[0]?.textContent).toBe("S"); + harness.unmount(); + }); }); diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 494099caaf..0e840d00cc 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -76,6 +76,7 @@ import { launchAndJoinAgent, findExistingAgentChat, removeWorkbenchParticipant, + resolveInvitedDisplayName, sendWorkbenchMessage, cancelWorkbenchTurn, } from "./workbench-service"; @@ -2974,12 +2975,15 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ); // Every one of the workbench's own agent participants, each resolved - // back to its definition id — the settings surface's Assistant - // section reads this before it can look up each definition's + // back to its definition id and person-facing display name — the + // timeline, mention picker, and presence stack render this name, never + // the raw handle slug (CL-6424). The settings surface's Assistant + // section reads the definition id before it looks up each definition's // name/instructions through `@corbits/agent-directory`. A workbench // with several invited agents lists every one of them, not just the - // first; a participant whose address no longer resolves to a live - // definition is simply omitted rather than failing the whole list. + // first; a participant whose address no longer resolves to a live, + // nameable definition is simply omitted rather than failing the whole + // list. app.get( "/workbenches/:id/agents", deps.requireGrant(idResource("workflow-run", "id"), "read"), @@ -3003,6 +3007,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const agentParticipants = participantsOf(existing.settings).filter( (participant) => isAgentAddress(participant.address), ); + const invitable = await deps.platform.listInvitableDefinitions(tenant.id); const items = ( await Promise.all( agentParticipants.map(async (participant) => { @@ -3013,14 +3018,29 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { if (definitionId === undefined) return null; const definitionAssetId = await deps.platform.resolveDefinitionAssetId(definitionId); - return definitionAssetId === undefined - ? null - : { - address: participant.address, - handle: participant.handle, - definitionId, - definitionAssetId, - }; + if (definitionAssetId === undefined) return null; + let displayName: string; + try { + displayName = await resolveInvitedDisplayName( + deps.platform, + invitable, + definitionId, + ); + } catch (err) { + reportError(err, { + operation: "chat.workbenchAgents.displayName", + tenantId: tenant.id, + roomId: workbenchId, + }); + return null; + } + return { + address: participant.address, + handle: participant.handle, + definitionId, + definitionAssetId, + displayName, + }; }), ) ).filter((item) => item !== null); diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 3e3d64e00b..2616f89a6c 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -1339,11 +1339,106 @@ describe("GET /workbenches/:id/agents", () => { const response = await app.request(`/workbenches/${workbench.id}/agents`); expect(response.status).toBe(200); const body = (await response.json()) as { - items: { address: string; handle: string; definitionId: string }[]; + items: { + address: string; + handle: string; + definitionId: string; + displayName: string; + }[]; }; expect(body.items).toHaveLength(1); expect(body.items[0]?.address).toBe("ins_invited1@acme.example"); expect(body.items[0]?.definitionId).toBe("wfd_echo"); + expect(body.items[0]?.displayName).toBe("Echo"); + }); + + test("prefers the definition's display name over its slug (CL-6424)", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [ + { id: "wfd_review", name: "code-review", description: "Reviewer" }, + ], + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_review" : undefined, + resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + name: "Test Workbench", + }); + await app.request(`/workbenches/${workbench.id}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_review" }), + }); + + const response = await app.request(`/workbenches/${workbench.id}/agents`); + expect(response.status).toBe(200); + const body = (await response.json()) as { + items: { address: string; displayName: string }[]; + }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.displayName).toBe("Reviewer"); + }); + + test("falls back to a live name lookup when the invitable snapshot predates the definition (CL-6424)", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [], + resolveDefinitionNameSource: async (definitionId) => + definitionId === "wfd_echo" + ? { name: "echo", description: "Myra" } + : undefined, + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, + resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + name: "Test Workbench", + }); + await app.request(`/workbenches/${workbench.id}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_echo" }), + }); + + const response = await app.request(`/workbenches/${workbench.id}/agents`); + expect(response.status).toBe(200); + const body = (await response.json()) as { + items: { address: string; displayName: string }[]; + }; + expect(body.items).toHaveLength(1); + expect(body.items[0]?.displayName).toBe("Myra"); + }); + + test("omits a participant whose definition has no name source left (CL-6424)", async () => { + const deps = buildDeps({ + platform: fakePlatform({ + invitable: [], + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_ghost" : undefined, + resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, + }), + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: workbench } = await createWorkbench(app, { + kind: "workbench", + name: "Test Workbench", + }); + await app.request(`/workbenches/${workbench.id}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_ghost" }), + }); + + const response = await app.request(`/workbenches/${workbench.id}/agents`); + const body = (await response.json()) as { items: unknown[] }; + expect(body.items).toEqual([]); }); test("lists every invited agent when a workbench has more than one", async () => {