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
46 changes: 43 additions & 3 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
} from "./mentions";
import type { BringInListFailure, BringInMember } from "./mentions";
import { PinnedStrip } from "./pinned-strip";
import { SLASH_COMMANDS } from "./slash-commands";

import { CHAT_STRINGS } from "./strings";
import { displayWorkbenchTitle } from "./workbench-display-title";
import { useStreamingReply, typingAgentNames } from "./streaming-reply";
Expand Down Expand Up @@ -278,6 +280,25 @@ export function bringInLoadErrorMessage(
return describeChatError(firstError, fallback);
}

/**
* CL-6781: the header Invite control must not open an empty "who to invite"
* dead end. Hide while the invitable listing is still in flight (or errored),
* and hide once a successful listing proves nobody — agent or person — is
* left to bring in. Kind gating stays with `canInviteAgent`.
*/
export function shouldOfferInviteControl(args: {
readonly kind: string | undefined;
/** Successful invitable-definitions listing; `undefined` while loading/errored. */
readonly invitableAgents: readonly unknown[] | undefined;
/** Successful bring-in people listing when the host wired `listMembers`. */
readonly bringInMembers?: readonly unknown[];
}): boolean {
if (!canInviteAgent(args.kind)) return false;
if (args.invitableAgents === undefined) return false;
if (args.invitableAgents.length > 0) return true;
return (args.bringInMembers?.length ?? 0) > 0;
}

/**
* Recovery controls for a gone / non-workbench id (CL-6796). Prefer the
* Mission Control + New workbench pair; fall back to the legacy single
Expand Down Expand Up @@ -324,6 +345,9 @@ export function workbenchNotFoundRecoveryAction(args: {
* `POST /workbenches`), so it's always the right word here even when the
* counterpart is a person, not an agent. A workbench (or a surface that
* hasn't resolved yet) keeps the generic, mention-driven copy.
*
* CL-6740: only advertise "/ for commands" when the slash catalog actually
* has commands — an empty/disabled catalog must not promise a dead hop.
*/
export function composerPlaceholderFor(
workbench:
Expand All @@ -332,15 +356,21 @@ export function composerPlaceholderFor(
readonly title: string;
}
| undefined,
options?: { readonly slashCommandCount?: number },
): string {
const slashAvailable =
(options?.slashCommandCount ?? SLASH_COMMANDS.length) > 0;
if (workbench === undefined || workbench.kind !== "chat") {
return CHAT_STRINGS.composerPlaceholder;
return slashAvailable
? `${CHAT_STRINGS.composerPlaceholder}, / for commands`
: CHAT_STRINGS.composerPlaceholder;
}
const counterpart =
workbench.title.trim().length > 0
? workbench.title
: CHAT_STRINGS.unnamedWorkbench;
return CHAT_STRINGS.composerPlaceholderChat(counterpart);
const base = CHAT_STRINGS.composerPlaceholderChat(counterpart);
return slashAvailable ? `${base} / for commands` : base;
}

/**
Expand Down Expand Up @@ -1097,6 +1127,16 @@ function ChatWorkspaceInner({
bringInLists.firstError,
);

const offerInviteControl = shouldOfferInviteControl({
kind: activeWorkbench?.kind,
invitableAgents: invitableAgentsQuery.isSuccess
? (invitableAgentsQuery.data ?? [])
: undefined,
...(bringInMembersQuery.isSuccess
? { bringInMembers: bringInMembersQuery.data ?? [] }
: {}),
});

// A settings URL for a workbench id that resolved workbenches don't contain
// (deleted, mistyped, cross-tenant) would otherwise leave the surface
// silently showing the ordinary chat view under a lying /settings URL —
Expand Down Expand Up @@ -1407,7 +1447,7 @@ function ChatWorkspaceInner({
) : null}
</div>
) : null}
{canInviteAgent(activeWorkbench?.kind) ? (
{offerInviteControl ? (
<Button
variant="outline"
size="sm"
Expand Down
6 changes: 3 additions & 3 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export const CHAT_STRINGS = {
workbenchNotFoundAction: "Back to workbenches",
workbenchNotFoundMissionControlAction: "Mission Control",
workbenchNotFoundNewWorkbenchAction: "New workbench",
composerPlaceholder:
"Send a message… use @ to mention an agent, / for commands",
composerPlaceholderChat: (name: string) => `Message ${name}… / for commands`,
composerPlaceholder: "Send a message… use @ to mention an agent",
composerPlaceholderChat: (name: string) => `Message ${name}…`,

composerSend: "Send",
composerSending: "Sending…",
composerKeyboardHint: "Enter to send",
Expand Down
73 changes: 73 additions & 0 deletions packages/chat-ui/test/chat-workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1832,6 +1832,79 @@ describe("CL-6833: running-turn resume failure is visible, never silent idle", (
});
});

describe("Invite control visibility (CL-6781)", () => {
test("hides Invite agent when the invitable listing succeeds empty", async () => {
stubFetch();
const harness = await mount({
tenant: { kind: "ready", tenantId: "tnt_1" },
workbenchId: "ch_1",
});
await harness.settle();
await harness.settle();

expect(harness.container.textContent).not.toContain("Invite agent");
harness.unmount();
});

test("shows Invite agent once at least one definition is invitable", async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const path = typeof input === "string" ? input : String(input);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (/\/chat\/workbenches\?kind=workbench$/.test(path)) {
return json({ items: [WORKBENCH_WIRE] });
}
if (/\/chat\/workbenches\?kind=chat$/.test(path))
return json({ items: [] });
if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) {
return json({ rootThreadId: "", items: [] });
}
if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) {
if (init?.method === "POST") {
return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" });
}
return json({ items: [] });
}
if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({});
if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) {
return json({
items: [{ id: "wfd_echo", name: "echo", description: "Echo" }],
});
}
if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path))
return json({ items: [] });
if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) {
return json({
...WORKBENCH_WIRE,
settings: {},
contextWindow: { value: 20, source: "inherit" },
});
}
if (/\/chat\/bench\/settings$/.test(path)) {
return json({ settings: {}, contextWindow: 20 });
}
throw new Error(`unstubbed fetch: ${path}`);
}) as typeof fetch;

const harness = await mount({
tenant: { kind: "ready", tenantId: "tnt_1" },
workbenchId: "ch_1",
});
await harness.settle();
await harness.settle();

expect(harness.container.textContent).toContain("Invite agent");
harness.unmount();
});
});

describe("switching workbenches never carries a stale root-thread id across", () => {
// CL-6067/6069 regression, still guarded after CL-6313 made it
// structurally impossible: the timeline no longer fetches by thread id
Expand Down
90 changes: 83 additions & 7 deletions packages/chat-ui/test/refresh-and-send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
composerPlaceholderFor,
mergePendingSends,
mergeStreamingReply,
shouldOfferInviteControl,
withScrollSnapshot,
} from "../src/chat-workspace";
import type { PendingSend } from "../src/chat-workspace";
Expand All @@ -30,6 +31,7 @@ import {
validateAttachmentPick,
} from "../src/composer";
import type { ComposerAttachment } from "../src/composer";
import { SLASH_COMMANDS } from "../src/slash-commands";
import { CHAT_STRINGS } from "../src/strings";
import { backoffDelayMs, shouldConnect } from "../src/use-workbench-stream";

Expand Down Expand Up @@ -239,36 +241,110 @@ describe("canInviteAgent (a chat's agent is fixed at creation; the server 409s a
});
});

describe("composerPlaceholderFor (CL-6070: a chat's composer reads as a DM, not a workbench)", () => {
test("names the counterpart for an agent chat", () => {
describe("shouldOfferInviteControl (CL-6781: Invite stays hidden until someone is invitable)", () => {
test("hides while the invitable listing has not succeeded yet", () => {
expect(
shouldOfferInviteControl({
kind: "workbench",
invitableAgents: undefined,
}),
).toBe(false);
});

test("hides when the listing succeeded with nobody left to invite", () => {
expect(
shouldOfferInviteControl({
kind: "workbench",
invitableAgents: [],
bringInMembers: [],
}),
).toBe(false);
});

test("shows once at least one invitable agent is available", () => {
expect(
shouldOfferInviteControl({
kind: "workbench",
invitableAgents: [{ id: "wfd_echo" }],
}),
).toBe(true);
});

test("shows when a person can still be brought in even if every agent is already in the room", () => {
expect(
shouldOfferInviteControl({
kind: "workbench",
invitableAgents: [],
bringInMembers: [{ id: "prn_bob" }],
}),
).toBe(true);
});

test("never offers Invite on a chat, even with invitable agents", () => {
expect(
shouldOfferInviteControl({
kind: "chat",
invitableAgents: [{ id: "wfd_echo" }],
}),
).toBe(false);
});
});

describe("composerPlaceholderFor (CL-6070 / CL-6740: names the open recipient; no / promise without slash commands)", () => {
test("names the counterpart for an agent chat from the workbench title — never a hardcoded agent", () => {
expect(composerPlaceholderFor({ kind: "chat", title: "Myra" })).toBe(
CHAT_STRINGS.composerPlaceholderChat("Myra"),
`${CHAT_STRINGS.composerPlaceholderChat("Myra")} / for commands`,
);
expect(composerPlaceholderFor({ kind: "chat", title: "Scout" })).toBe(
`${CHAT_STRINGS.composerPlaceholderChat("Scout")} / for commands`,
);
expect(
composerPlaceholderFor({ kind: "chat", title: "Scout" }),
).not.toContain("Myra");
});

test("names the counterpart for a person chat too — a chat's title is always its counterpart's name", () => {
expect(composerPlaceholderFor({ kind: "chat", title: "Priya" })).toBe(
CHAT_STRINGS.composerPlaceholderChat("Priya"),
`${CHAT_STRINGS.composerPlaceholderChat("Priya")} / for commands`,
);
});

test("keeps the generic workbench copy for a workbench", () => {
expect(
composerPlaceholderFor({ kind: "workbench", title: "General" }),
).toBe(CHAT_STRINGS.composerPlaceholder);
).toBe(`${CHAT_STRINGS.composerPlaceholder}, / for commands`);
});

test("keeps the generic copy with no workbench resolved yet", () => {
expect(composerPlaceholderFor(undefined)).toBe(
CHAT_STRINGS.composerPlaceholder,
`${CHAT_STRINGS.composerPlaceholder}, / for commands`,
);
});

test("falls back to the unnamed-workbench label for a titleless chat", () => {
expect(composerPlaceholderFor({ kind: "chat", title: "" })).toBe(
CHAT_STRINGS.composerPlaceholderChat(CHAT_STRINGS.unnamedWorkbench),
`${CHAT_STRINGS.composerPlaceholderChat(CHAT_STRINGS.unnamedWorkbench)} / for commands`,
);
});

test("advertises / for commands only when the slash catalog is non-empty", () => {
expect(SLASH_COMMANDS.length).toBeGreaterThan(0);
expect(
composerPlaceholderFor({ kind: "workbench", title: "General" }),
).toContain("/ for commands");
expect(
composerPlaceholderFor(
{ kind: "workbench", title: "General" },
{ slashCommandCount: 0 },
),
).not.toContain("/ for commands");
expect(
composerPlaceholderFor(
{ kind: "chat", title: "Priya" },
{ slashCommandCount: 0 },
),
).toBe("Message Priya…");
});
});

// CL-6677: this notice used to render as a bare `event`-kind item with no
Expand Down
Loading