diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index 6c6fc9ac..a6616da0 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; import { CODE_REVIEW_TEMPLATE, serializeWorkbenchTemplateManifest, @@ -9,6 +10,12 @@ import { NEW_WORKBENCH_TITLE, } from "./instant-agent-create"; +function newQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + describe("createWorkbenchFromTemplate (CL-6387)", () => { const realFetch = globalThis.fetch; @@ -70,11 +77,17 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { throw new Error(`unexpected fetch: ${path}`); }); - await createWorkbenchFromTemplate("tnt_1", "blank", (to) => - navigated.push(to), + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + (to) => navigated.push(to), + newQueryClient(), ); - await createWorkbenchFromTemplate("tnt_1", "blank", (to) => - navigated.push(to), + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + (to) => navigated.push(to), + newQueryClient(), ); const createCalls = calls.filter((call) => @@ -145,8 +158,18 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { throw new Error(`unexpected fetch: ${path}`); }); - await createWorkbenchFromTemplate("tnt_1", "code-review", (to) => - navigated.push(to), + const queryClient = newQueryClient(); + // Seed the cache the way a person browsing before creating this + // workbench would have: a `workbenches` list fetched before any of + // the reviewer roster below was invited. + const staleQueryKey = ["tenant", "tnt_1", "workbenches", "chat"] as const; + queryClient.setQueryData(staleQueryKey, { items: [] }); + + await createWorkbenchFromTemplate( + "tnt_1", + "code-review", + (to) => navigated.push(to), + queryClient, ); const createCall = calls.find((call) => @@ -174,5 +197,12 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { ); expect(invitedIds.sort()).toEqual(createdIds.sort()); expect(navigated).toEqual(["/w/chan-1"]); + + // CL-6594: a room this function navigates to must never carry a + // `workbenches` cache captured before its own reviewer roster + // finished being invited — that staleness is what left an invited + // agent with no name, no avatar, and no `@mention` in the room the + // owner reported it from. + expect(queryClient.getQueryState(staleQueryKey)?.isInvalidated).toBe(true); }); }); diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index a83fc3cf..0ff569fa 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -14,12 +14,14 @@ // (Settings → Agents), unchanged. import { getLogger } from "@corbits/client-log"; +import type { QueryClient } from "@tanstack/react-query"; import { createWorkbench, getConnectGithubState, inviteAgent, patchWorkbenchSettings, startReviewingGithubRepos, + workbenchesQueryKeyPrefix, type ConnectGithubRepo, } from "@corbits/chat-ui"; import { listPluginsForTenant } from "@workbench/connections/plugins"; @@ -111,11 +113,22 @@ export type PickGithubRepos = (args: { * GitHub is already connected for this tenant, this also drives * CL-6386's "select on new-workbench" step — see `PickGithubRepos`'s * own doc. + * + * `queryClient` invalidates the workbenches list once every template + * participant has been invited (CL-6594) — `ChatWorkspace`'s own + * in-room "Invite agent" dialog does the same + * (`workbenchesQueryKeyPrefix`, `chat-workspace.tsx`'s + * `refreshWorkbenchLists`) so the room the invite landed in never + * shows a participant it already has data for as if it never joined. + * Without this, the room this function `navigate`s to can start life + * holding a `workbenches` query cached from before the last invite + * resolved. */ export async function createWorkbenchFromTemplate( tenantId: string, templateId: WorkbenchTemplateId, navigate: (to: string) => void, + queryClient: QueryClient, pickGithubRepos?: PickGithubRepos, ): Promise { const definitions = await listAgentDefinitions(tenantId); @@ -217,6 +230,9 @@ export async function createWorkbenchFromTemplate( for (const todo of result.webhookTriggerTodos) { log.error(todo); } + await queryClient.invalidateQueries({ + queryKey: workbenchesQueryKeyPrefix(tenantId), + }); } navigate(workbenchPath(workbench.id)); diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 2104e7d2..8d1f8453 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -21,6 +21,7 @@ import { WorkbenchLoadingState, } from "@corbits/chat-ui"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { getLogger } from "@corbits/client-log"; import { ApiQueryError, describeApiError } from "@corbits/api-query"; @@ -94,6 +95,7 @@ const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank"; export function NewWorkbenchPickerRoute() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const { selectedTenantId } = useBench(); const library = useAPIQuery( selectedTenantId === null @@ -147,6 +149,7 @@ export function NewWorkbenchPickerRoute() { selectedTenantId, selectedId, navigate, + queryClient, pickGithubRepos, ); } catch (cause) { diff --git a/packages/chat-ui/src/chat-workspace.test.ts b/packages/chat-ui/src/chat-workspace.test.ts new file mode 100644 index 00000000..b274f7d0 --- /dev/null +++ b/packages/chat-ui/src/chat-workspace.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import { buildTeamAvatarStack } from "./chat-workspace"; +import type { ParticipantRecord } from "./api"; + +describe("buildTeamAvatarStack (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, []); + + expect(stack).toHaveLength(2); + expect(stack.map((entry) => entry.initials)).toEqual(["M", "S"]); + expect(stack.map((entry) => entry.label)).toEqual(["myra", "scout"]); + expect(stack.every((entry) => entry.tone === "agent")).toBe(true); + + const [myra, scout] = stack; + expect(myra?.color).toBeDefined(); + expect(scout?.color).toBeDefined(); + // Distinct addresses must never collapse onto the same fallback + // fill — this is exactly what a shared CSS accent color did before + // CL-6594: two agents in one room rendered as indistinguishable + // avatars. + expect(myra?.color).not.toBe(scout?.color); + }); + + test("keeps every agent visible alongside live humans, agents first", () => { + 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", + ]); + }); +}); diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index a9a2fe67..5dc26c20 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -155,6 +155,12 @@ export const TEAM_AVATAR_STACK_LIMIT = 6; * are always "active" — they have no presence concept of their own) plus * every human currently reflected in live presence. Agents first since * they're a workbench's stable roster; humans are who's here right now. + * + * Each agent gets its own `generatedAvatarStyle` fill keyed by address + * (CL-6594) — the same deterministic-per-principal machinery humans + * already use — rather than one shared CSS accent color for every + * agent, so two agents in the same room never render as + * indistinguishable avatars. */ export function buildTeamAvatarStack( participants: readonly ParticipantRecord[], @@ -162,12 +168,17 @@ export function buildTeamAvatarStack( ): readonly TeamAvatarEntry[] { const agents = participants .filter((participant) => isAgentAddress(participant.address)) - .map((participant) => ({ - key: participant.address, - initials: participant.handle, - label: participant.handle, - tone: "agent" as const, - })); + .map((participant) => { + const style = generatedAvatarStyle(participant.address); + return { + key: participant.address, + initials: participant.handle.slice(0, 1).toUpperCase(), + label: participant.handle, + tone: "agent" as const, + color: style["--avatar-identity-bg"], + textColor: style["--avatar-identity-fg"], + }; + }); const humans = presenceMembers.map((member) => ({ key: member.principalId, initials: member.displayName.slice(0, 1).toUpperCase(), @@ -1142,30 +1153,22 @@ function ChatWorkspaceInner({ className="chat-team-stack" aria-label={CHAT_STRINGS.workbenchMembersLabel} > - {visibleTeamStack.map((entry) => - entry.tone === "agent" ? ( - - {entry.initials.slice(0, 1).toUpperCase()} - - ) : ( - - {entry.initials} - - ), - )} + {visibleTeamStack.map((entry) => ( + + {entry.initials} + + ))} {teamStackOverflow > 0 ? ( { + test("names the agent by its participant handle", () => { + const participants: readonly ParticipantRecord[] = [ + { address: "run_scout@dana.localhost", handle: "scout" }, + ]; + expect( + friendlyEventText( + agentJoinedPart("run_scout@dana.localhost"), + participants, + ), + ).toBe("Scout joined"); + }); + + test("falls back to the address's own local part, never a generic noun, when the roster hasn't caught up with this address yet", () => { + const participants: readonly ParticipantRecord[] = [ + { address: "run_myra@dana.localhost", handle: "myra" }, + ]; + expect( + friendlyEventText( + agentJoinedPart("run_scout@dana.localhost"), + participants, + ), + ).toBe("Run Scout joined"); + }); + + test("falls back to the generic line only when the event itself carries no address at all", () => { + const part: Part & { kind: "event" } = { + kind: "event", + event: "workbench.agent-joined", + data: {}, + }; + expect(friendlyEventText(part, [])).toBe("An agent joined"); + }); +}); diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 089dae44..f1d30e55 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -577,7 +577,7 @@ function TextBubble({ * anything else falls back to the event name with its separators turned * into spaces. */ -function friendlyEventText( +export function friendlyEventText( part: Part & { kind: "event" }, participants: readonly ParticipantRecord[], ): string { @@ -589,10 +589,15 @@ function friendlyEventText( data !== undefined && typeof data.address === "string" ? data.address : undefined; + // The participant record's own handle is the friendly, settings-held + // name (see `packages/chat/src/participants.ts`); when the roster + // hasn't caught up with this address yet, the address's own local + // part (CL-6594) is still a real identifier — never the generic "An + // agent joined", which hides a name the event already carries. const handle = address !== undefined - ? participants.find((participant) => participant.address === address) - ?.handle + ? (participants.find((participant) => participant.address === address) + ?.handle ?? localPartOf(address)) : undefined; switch (part.event) { diff --git a/packages/chat-ui/test/team-avatar-stack.test.tsx b/packages/chat-ui/test/team-avatar-stack.test.tsx index 799fde27..2d163c29 100644 --- a/packages/chat-ui/test/team-avatar-stack.test.tsx +++ b/packages/chat-ui/test/team-avatar-stack.test.tsx @@ -170,6 +170,45 @@ describe("workbench header team avatar stack", () => { harness.unmount(); }); + test("gives every agent its own initial and color, never a shared fallback (CL-6594)", async () => { + stubFetch({ + participants: [ + { address: "run_myra@dana.localhost", handle: "myra" }, + { address: "run_scout@dana.localhost", handle: "scout" }, + ], + }); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + }); + await harness.settle(); + + const agentAvatars = Array.from( + harness.container.querySelectorAll( + '.chat-presence-avatar[data-agent="true"]', + ), + ) as HTMLElement[]; + expect(agentAvatars).toHaveLength(2); + expect(agentAvatars.map((avatar) => avatar.textContent)).toEqual([ + "M", + "S", + ]); + // Each agent avatar carries its own `AVATAR_IDENTITY_CLASS`-free + // inline text color (the CSS custom-property indirection this + // package uses elsewhere doesn't apply to this chip), proving two + // agents never render with the exact same computed fill — the + // per-address color itself is `buildTeamAvatarStack`'s own unit + // test (`../src/chat-workspace.test.ts`), since happy-dom drops the + // space-syntax `hsl()` `colorForPrincipal` emits before it reaches + // any DOM assertion here. + const [myraText, scoutText] = agentAvatars.map( + (avatar) => avatar.style.color, + ); + expect(myraText).not.toBe(""); + expect(scoutText).not.toBe(""); + harness.unmount(); + }); + test("collapses anything past the limit into a +N chip", async () => { const humanNames = ["Alice", "Bob", "Carla", "Dana", "Eve", "Finn"]; const humanParticipants = humanNames.map((name, index) =>