Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -304,13 +305,17 @@ export function ChatPage({
onNewWorkbench={() => navigate(NEW_WORKBENCH_PATH)}
onBackToWorkbenchList={() => navigate(workbenchPath(null))}
{...(onSignIn !== undefined ? { onSignIn } : {})}
headerSlot={(chrome) => (
<StageTopBar
crumbs={chrome.crumbs}
{...(chrome.subtitle !== undefined
? { subtitle: chrome.subtitle }
: {})}
{...(chrome.actions !== undefined ? { actions: chrome.actions } : {})}
/>
)}
/>
);

// 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 <div className="flex h-full min-h-0 flex-col">{workspace}</div>;
}
12 changes: 7 additions & 5 deletions apps/web/test/routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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");
});
});
16 changes: 16 additions & 0 deletions apps/web/test/stage-top-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down
36 changes: 36 additions & 0 deletions packages/chat-ui/src/agent-display-names.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

/**
* 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);
}
1 change: 1 addition & 0 deletions packages/chat-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,7 @@ const WorkbenchAgentWire = type({
handle: "string",
definitionId: "string",
definitionAssetId: "string",
displayName: "string",
});
export type WorkbenchAgent = typeof WorkbenchAgentWire.infer;

Expand Down
67 changes: 33 additions & 34 deletions packages/chat-ui/src/chat-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)", () => {
Expand All @@ -61,33 +51,42 @@ 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");
expect(human?.initials).toBe("D");
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"]);
});
});

Expand Down
Loading
Loading