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
30 changes: 29 additions & 1 deletion packages/chat-ui/src/chat-workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -49,3 +53,27 @@ 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);
});
});
38 changes: 34 additions & 4 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ 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";
Expand Down Expand Up @@ -211,6 +214,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
Expand Down Expand Up @@ -996,6 +1017,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
Expand Down Expand Up @@ -1467,8 +1496,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)}
Expand Down
19 changes: 17 additions & 2 deletions packages/chat-ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
/** `/invite` — opens the invite-agent dialog. */
Expand All @@ -334,6 +340,7 @@ export const Composer = forwardRef<
participants = [],
members = [],
invitableAgents = [],
bringInLoadError = null,
onSend,
onInviteAgent,
onOpenAgentsSettings,
Expand Down Expand Up @@ -692,11 +699,19 @@ export const Composer = forwardRef<
{slash === null && mention !== null && (
<div className="chat-mention-popover chat-popover-enter" role="listbox">
{mentionOptions.length === 0 ? (
<div className="chat-mention-empty">
{CHAT_STRINGS.mentionEmpty}
<div
className="chat-mention-empty"
{...(bringInLoadError !== null ? { role: "alert" as const } : {})}
>
{bringInLoadError ?? CHAT_STRINGS.mentionEmpty}
</div>
) : (
<div className="chat-mention-list">
{bringInLoadError !== null ? (
<div className="chat-mention-empty" role="alert">
{bringInLoadError}
</div>
) : null}
{mentionOptions.map((option, index) => {
const prev = mentionOptions[index - 1];
const showSection =
Expand Down
2 changes: 2 additions & 0 deletions packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ export {
insertMention,
mentionCandidatesFromParticipants,
mentionOptionsFromWorkbench,
resolveBringInLists,
} from "./mentions";
export type {
BringInAgentDefinition,
BringInListFailure,
BringInMember,
MentionCandidate,
MentionInviteIntent,
Expand Down
44 changes: 44 additions & 0 deletions packages/chat-ui/src/mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
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 BringInMember[]>;
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,
};
}
3 changes: 3 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
68 changes: 68 additions & 0 deletions packages/chat-ui/test/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
71 changes: 71 additions & 0 deletions packages/chat-ui/test/mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
insertMention,
mentionCandidatesFromParticipants,
mentionOptionsFromWorkbench,
resolveBringInLists,
} from "../src/mentions";

describe("activeMentionQuery", () => {
Expand Down Expand Up @@ -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);
});
});
Loading