From 6e0591c46f2087e0f22dfd3158eba54fc5a7713b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:03 -0700 Subject: [PATCH 1/3] CL-6839: Add tests for bring-in and invite list query errors --- packages/chat-ui/src/chat-workspace.test.ts | 33 +++++++++- packages/chat-ui/test/composer.test.tsx | 68 ++++++++++++++++++++ packages/chat-ui/test/mentions.test.ts | 71 +++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index b274f7d09..b6811da57 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { buildTeamAvatarStack } from "./chat-workspace"; +import { + bringInLoadErrorMessage, + buildTeamAvatarStack, +} from "./chat-workspace"; import type { ParticipantRecord } from "./api"; +import { CHAT_STRINGS } from "./strings"; describe("buildTeamAvatarStack (CL-6594)", () => { test("gives every agent participant its own initial and its own generated color, never a shared fallback", () => { @@ -49,3 +53,30 @@ describe("buildTeamAvatarStack (CL-6594)", () => { ]); }); }); + +describe("bringInLoadErrorMessage (CL-6839)", () => { + test("no failures yields null — honest empty stays empty", () => { + expect(bringInLoadErrorMessage([], null)).toBeNull(); + }); + + test("members-only failure uses the people copy", () => { + expect(bringInLoadErrorMessage(["members"], new Error("x"))).toBe( + CHAT_STRINGS.mentionMembersLoadError, + ); + }); + + test("invitable-agents-only failure uses the agents copy", () => { + expect(bringInLoadErrorMessage(["invitableAgents"], new Error("x"))).toBe( + CHAT_STRINGS.mentionInvitableLoadError, + ); + }); + + test("both failures use the combined copy", () => { + expect( + bringInLoadErrorMessage( + ["members", "invitableAgents"], + new Error("x"), + ), + ).toBe(CHAT_STRINGS.mentionBringInLoadError); + }); +}); diff --git a/packages/chat-ui/test/composer.test.tsx b/packages/chat-ui/test/composer.test.tsx index 57ba344cd..518d18777 100644 --- a/packages/chat-ui/test/composer.test.tsx +++ b/packages/chat-ui/test/composer.test.tsx @@ -340,3 +340,71 @@ describe("Composer hit targets (CL-6250)", () => { expect(buttons?.length).toBe(2); }); }); + +describe("Composer mention bring-in load error (CL-6839)", () => { + function mountWithBringInError(bringInLoadError: string) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + participants: [], + members: [], + invitableAgents: [], + bringInLoadError, + onSend: () => Promise.resolve(true), + onInviteAgent: () => undefined, + onOpenAgentsSettings: () => undefined, + onCreateRoutineInSpace: () => undefined, + }), + ); + }); + } + + test("shows the load error instead of an honest empty 'No matches' list", async () => { + mountWithBringInError("Couldn't load people and agents to bring in"); + typeInto(textarea(), "@"); + await settle(); + + const empty = container?.querySelector(".chat-mention-empty"); + expect(empty?.getAttribute("role")).toBe("alert"); + expect(empty?.textContent).toBe( + "Couldn't load people and agents to bring in", + ); + expect(container?.textContent).not.toContain("No matches"); + }); + + test("keeps in-workbench matches visible and still surfaces the bring-in error", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + createElement(Composer, { + agents: [], + participants: [ + { address: "researcher@agents.example", handle: "researcher" }, + ], + members: [], + invitableAgents: [], + bringInLoadError: "Couldn't load agents to bring in", + onSend: () => Promise.resolve(true), + onInviteAgent: () => undefined, + onOpenAgentsSettings: () => undefined, + onCreateRoutineInSpace: () => undefined, + }), + ); + }); + typeInto(textarea(), "@"); + await settle(); + + const alert = container?.querySelector('.chat-mention-empty[role="alert"]'); + expect(alert?.textContent).toBe("Couldn't load agents to bring in"); + const handles = Array.from( + container?.querySelectorAll(".chat-mention-handle") ?? [], + ).map((node) => node.textContent); + expect(handles).toEqual(["@researcher"]); + }); +}); diff --git a/packages/chat-ui/test/mentions.test.ts b/packages/chat-ui/test/mentions.test.ts index 3b2e325e6..5119e647d 100644 --- a/packages/chat-ui/test/mentions.test.ts +++ b/packages/chat-ui/test/mentions.test.ts @@ -8,6 +8,7 @@ import { insertMention, mentionCandidatesFromParticipants, mentionOptionsFromWorkbench, + resolveBringInLists, } from "../src/mentions"; describe("activeMentionQuery", () => { @@ -201,3 +202,73 @@ describe("insertMention", () => { expect(result.text).toBe("hi @researcher please"); }); }); + +describe("resolveBringInLists (CL-6839)", () => { + const members = [{ id: "prn_bob", displayName: "Bob" }]; + const agents = [{ id: "wfd_echo", name: "echo" }]; + + test("successful queries pass their data through", () => { + expect( + resolveBringInLists({ + members: { data: members, isError: false, error: null }, + invitableAgents: { data: agents, isError: false, error: null }, + }), + ).toEqual({ + members, + invitableAgents: agents, + failures: [], + firstError: null, + }); + }); + + test("undefined data while idle/loading is an empty list, not a failure", () => { + expect( + resolveBringInLists({ + members: { data: undefined, isError: false, error: null }, + invitableAgents: { data: undefined, isError: false, error: null }, + }), + ).toEqual({ + members: [], + invitableAgents: [], + failures: [], + firstError: null, + }); + }); + + test("a members query error is a failure, never an honest empty members list", () => { + const err = new Error("members boom"); + const resolved = resolveBringInLists({ + members: { data: undefined, isError: true, error: err }, + invitableAgents: { data: agents, isError: false, error: null }, + }); + expect(resolved.members).toEqual([]); + expect(resolved.invitableAgents).toEqual(agents); + expect(resolved.failures).toEqual(["members"]); + expect(resolved.firstError).toBe(err); + }); + + test("an invitable-agents query error is a failure, never an honest empty agents list", () => { + const err = new Error("agents boom"); + const resolved = resolveBringInLists({ + members: { data: members, isError: false, error: null }, + invitableAgents: { data: undefined, isError: true, error: err }, + }); + expect(resolved.members).toEqual(members); + expect(resolved.invitableAgents).toEqual([]); + expect(resolved.failures).toEqual(["invitableAgents"]); + expect(resolved.firstError).toBe(err); + }); + + test("both query errors surface both failures without inventing empty success", () => { + const membersErr = new Error("members boom"); + const agentsErr = new Error("agents boom"); + const resolved = resolveBringInLists({ + members: { data: undefined, isError: true, error: membersErr }, + invitableAgents: { data: undefined, isError: true, error: agentsErr }, + }); + expect(resolved.members).toEqual([]); + expect(resolved.invitableAgents).toEqual([]); + expect(resolved.failures).toEqual(["members", "invitableAgents"]); + expect(resolved.firstError).toBe(membersErr); + }); +}); From 897163196a142f8f9bb4e6ebafc3c9a794ddc87f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:03 -0700 Subject: [PATCH 2/3] CL-6839: Show error when bring-in and invite lists fail to load --- packages/chat-ui/src/chat-workspace.tsx | 35 +++++++++++++++++--- packages/chat-ui/src/composer.tsx | 19 +++++++++-- packages/chat-ui/src/index.ts | 2 ++ packages/chat-ui/src/mentions.ts | 44 +++++++++++++++++++++++++ packages/chat-ui/src/strings.ts | 3 ++ 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 7cb26e5bd..f35d743f4 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -46,8 +46,8 @@ import { Composer } from "./composer"; import type { ComposerHandle } from "./composer"; import { InviteAgentDialog } from "./invite-agent-dialog"; import { WorkbenchLoadingState } from "./loading-state"; -import { mentionCandidatesFromParticipants } from "./mentions"; -import type { BringInMember } from "./mentions"; +import { mentionCandidatesFromParticipants, resolveBringInLists } from "./mentions"; +import type { BringInListFailure, BringInMember } from "./mentions"; import { PinnedStrip } from "./pinned-strip"; import { CHAT_STRINGS } from "./strings"; import { displayWorkbenchTitle } from "./workbench-display-title"; @@ -211,6 +211,24 @@ export function canInviteAgent(kind: string | undefined): boolean { return !isKnownWorkbenchKind(kind) || kind !== "chat"; } +/** + * User-facing copy when the mention popover's bring-in queries fail + * (CL-6839) — never collapse those failures into an honest empty list. + */ +export function bringInLoadErrorMessage( + failures: readonly BringInListFailure[], + firstError: unknown | null, +): string | null { + if (failures.length === 0) return null; + const fallback = + failures.length === 2 + ? CHAT_STRINGS.mentionBringInLoadError + : failures[0] === "members" + ? CHAT_STRINGS.mentionMembersLoadError + : CHAT_STRINGS.mentionInvitableLoadError; + return describeChatError(firstError, fallback); +} + /** * Recovery controls for a gone / non-workbench id (CL-6796). Prefer the * Mission Control + New workbench pair; fall back to the legacy single @@ -996,6 +1014,14 @@ function ChatWorkspaceInner({ listMembers !== undefined ? listMembers(tenantId) : Promise.resolve([]), enabled: bringInEnabled && listMembers !== undefined, }); + const bringInLists = resolveBringInLists({ + members: bringInMembersQuery, + invitableAgents: invitableAgentsQuery, + }); + const bringInLoadError = bringInLoadErrorMessage( + bringInLists.failures, + bringInLists.firstError, + ); // A settings URL for a workbench id that resolved workbenches don't contain // (deleted, mistyped, cross-tenant) would otherwise leave the surface @@ -1467,8 +1493,9 @@ function ChatWorkspaceInner({ activeWorkbench?.participants ?? [], )} participants={activeWorkbench?.participants ?? []} - members={bringInMembersQuery.data ?? []} - invitableAgents={invitableAgentsQuery.data ?? []} + members={bringInLists.members} + invitableAgents={bringInLists.invitableAgents} + bringInLoadError={bringInLoadError} placeholder={composerPlaceholderFor(activeWorkbench)} onSend={handleSend} onInviteAgent={() => setInviteDialogOpen(true)} diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx index 87dcd37b1..534608694 100644 --- a/packages/chat-ui/src/composer.tsx +++ b/packages/chat-ui/src/composer.tsx @@ -316,6 +316,12 @@ export const Composer = forwardRef< /** Invitable agent definitions — the "Bring in…" group's agent * half. Defaults to empty (no group rendered). */ readonly invitableAgents?: readonly BringInAgentDefinition[]; + /** + * Bring-in members/invitable queries failed — show this instead of an + * honest-looking empty "No matches" / missing bring-in roster + * (CL-6839). Null/omitted means those queries succeeded or are idle. + */ + readonly bringInLoadError?: string | null; /** Resolves to whether the send succeeded; the composer decides draft/attachment cleanup from that. */ readonly onSend: (payload: ComposerSendPayload) => Promise; /** `/invite` — opens the invite-agent dialog. */ @@ -334,6 +340,7 @@ export const Composer = forwardRef< participants = [], members = [], invitableAgents = [], + bringInLoadError = null, onSend, onInviteAgent, onOpenAgentsSettings, @@ -692,11 +699,19 @@ export const Composer = forwardRef< {slash === null && mention !== null && (
{mentionOptions.length === 0 ? ( -
- {CHAT_STRINGS.mentionEmpty} +
+ {bringInLoadError ?? CHAT_STRINGS.mentionEmpty}
) : (
+ {bringInLoadError !== null ? ( +
+ {bringInLoadError} +
+ ) : null} {mentionOptions.map((option, index) => { const prev = mentionOptions[index - 1]; const showSection = diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index d103a7e04..b887fa0d6 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -62,9 +62,11 @@ export { insertMention, mentionCandidatesFromParticipants, mentionOptionsFromWorkbench, + resolveBringInLists, } from "./mentions"; export type { BringInAgentDefinition, + BringInListFailure, BringInMember, MentionCandidate, MentionInviteIntent, diff --git a/packages/chat-ui/src/mentions.ts b/packages/chat-ui/src/mentions.ts index 94989d736..f00485615 100644 --- a/packages/chat-ui/src/mentions.ts +++ b/packages/chat-ui/src/mentions.ts @@ -275,3 +275,47 @@ export function insertMention( caret: before.length + inserted.length, }; } + +/** Which bring-in query failed — callers map these to user-facing copy. */ +export type BringInListFailure = "members" | "invitableAgents"; + +type BringInQuerySlice = { + readonly data: T | undefined; + readonly isError: boolean; + readonly error: unknown; +}; + +/** + * Resolve the mention popover's bring-in candidate lists from their React + * Query slices. A failed query must never collapse to an honest-looking + * empty roster (`data ?? []` alone) — callers surface `failures` / + * `firstError` instead of rendering "no one to bring in". + */ +export function resolveBringInLists(input: { + readonly members: BringInQuerySlice; + readonly invitableAgents: BringInQuerySlice< + readonly BringInAgentDefinition[] + >; +}): { + readonly members: readonly BringInMember[]; + readonly invitableAgents: readonly BringInAgentDefinition[]; + readonly failures: readonly BringInListFailure[]; + readonly firstError: unknown | null; +} { + const failures: BringInListFailure[] = []; + if (input.members.isError) failures.push("members"); + if (input.invitableAgents.isError) failures.push("invitableAgents"); + return { + members: input.members.isError ? [] : (input.members.data ?? []), + invitableAgents: input.invitableAgents.isError + ? [] + : (input.invitableAgents.data ?? []), + failures, + firstError: + failures[0] === "members" + ? input.members.error + : failures[0] === "invitableAgents" + ? input.invitableAgents.error + : null, + }; +} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 4b6c2f845..13c2e2349 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -67,6 +67,9 @@ export const CHAT_STRINGS = { mentionAgentsGroupLabel: "Agents", mentionPeopleGroupLabel: "People", mentionForbidden: "You can't add people to this workbench", + mentionBringInLoadError: "Couldn't load people and agents to bring in", + mentionMembersLoadError: "Couldn't load people to bring in", + mentionInvitableLoadError: "Couldn't load agents to bring in", composerSlashEmpty: "No matching commands", composerSummarizeNoAgentError: "No agent in this conversation to summarize for — invite one first.", From 89948a0d930f278110426f84d8a77aca908a5243 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 09:28:42 -0700 Subject: [PATCH 3/3] Format files changed in this PR --- packages/chat-ui/src/chat-workspace.test.ts | 5 +---- packages/chat-ui/src/chat-workspace.tsx | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts index b6811da57..b7493a2e0 100644 --- a/packages/chat-ui/src/chat-workspace.test.ts +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -73,10 +73,7 @@ describe("bringInLoadErrorMessage (CL-6839)", () => { test("both failures use the combined copy", () => { expect( - bringInLoadErrorMessage( - ["members", "invitableAgents"], - new Error("x"), - ), + bringInLoadErrorMessage(["members", "invitableAgents"], new Error("x")), ).toBe(CHAT_STRINGS.mentionBringInLoadError); }); }); diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index f35d743f4..9a4872b74 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -46,7 +46,10 @@ import { Composer } from "./composer"; import type { ComposerHandle } from "./composer"; import { InviteAgentDialog } from "./invite-agent-dialog"; import { WorkbenchLoadingState } from "./loading-state"; -import { mentionCandidatesFromParticipants, resolveBringInLists } from "./mentions"; +import { + mentionCandidatesFromParticipants, + resolveBringInLists, +} from "./mentions"; import type { BringInListFailure, BringInMember } from "./mentions"; import { PinnedStrip } from "./pinned-strip"; import { CHAT_STRINGS } from "./strings";