From a3e3f9a179115371ae70946360607790c5d6de08 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:42 -0700 Subject: [PATCH 1/3] CL-6781: Add tests for invite visibility and composer promises --- packages/chat-ui/test/chat-workspace.test.tsx | 68 ++++++++++++++ .../chat-ui/test/refresh-and-send.test.ts | 90 +++++++++++++++++-- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 1b1e3e618..d93e6cc90 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -1832,6 +1832,74 @@ 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 diff --git a/packages/chat-ui/test/refresh-and-send.test.ts b/packages/chat-ui/test/refresh-and-send.test.ts index e4335de67..0041b2422 100644 --- a/packages/chat-ui/test/refresh-and-send.test.ts +++ b/packages/chat-ui/test/refresh-and-send.test.ts @@ -12,6 +12,7 @@ import { composerPlaceholderFor, mergePendingSends, mergeStreamingReply, + shouldOfferInviteControl, withScrollSnapshot, } from "../src/chat-workspace"; import type { PendingSend } from "../src/chat-workspace"; @@ -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"; @@ -239,35 +241,109 @@ 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…"); }); }); From abea4fd99d57d63ecd0f36f4d35750bbe2242240 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:42 -0700 Subject: [PATCH 2/3] CL-6781: Hide Invite until someone is invitable and fix composer promises --- packages/chat-ui/src/chat-workspace.tsx | 46 +++++++++++++++++++++++-- packages/chat-ui/src/strings.ts | 6 ++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 84cbe473e..d714e45bf 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -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"; @@ -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 @@ -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: @@ -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; } /** @@ -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 — @@ -1407,7 +1447,7 @@ function ChatWorkspaceInner({ ) : null} ) : null} - {canInviteAgent(activeWorkbench?.kind) ? ( + {offerInviteControl ? (