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
42 changes: 36 additions & 6 deletions apps/web/src/instant-agent-create.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { QueryClient } from "@tanstack/react-query";
import {
CODE_REVIEW_TEMPLATE,
serializeWorkbenchTemplateManifest,
Expand All @@ -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;

Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
});
});
16 changes: 16 additions & 0 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
const definitions = await listAgentDefinitions(tenantId);
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -147,6 +149,7 @@ export function NewWorkbenchPickerRoute() {
selectedTenantId,
selectedId,
navigate,
queryClient,
pickGithubRepos,
);
} catch (cause) {
Expand Down
51 changes: 51 additions & 0 deletions packages/chat-ui/src/chat-workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
63 changes: 33 additions & 30 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,19 +155,30 @@ 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[],
presenceMembers: readonly PresenceMember[],
): 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(),
Expand Down Expand Up @@ -1142,30 +1153,22 @@ function ChatWorkspaceInner({
className="chat-team-stack"
aria-label={CHAT_STRINGS.workbenchMembersLabel}
>
{visibleTeamStack.map((entry) =>
entry.tone === "agent" ? (
<span
key={entry.key}
className="chat-presence-avatar"
data-agent="true"
title={entry.label}
>
{entry.initials.slice(0, 1).toUpperCase()}
</span>
) : (
<span
key={entry.key}
className="chat-presence-avatar"
style={{
backgroundColor: entry.color,
color: entry.textColor,
}}
title={entry.label}
>
{entry.initials}
</span>
),
)}
{visibleTeamStack.map((entry) => (
<span
key={entry.key}
className="chat-presence-avatar"
data-agent={
entry.tone === "agent" ? "true" : undefined
}
style={{
backgroundColor: entry.color,
color: entry.textColor,
}}
title={entry.label}
>
{entry.initials}
</span>
))}
{teamStackOverflow > 0 ? (
<span
className="chat-team-stack-overflow"
Expand Down
7 changes: 0 additions & 7 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2841,13 +2841,6 @@
margin-left: 0;
}

/* Agents in the header stack keep their accent color but share the
humans' circular shape — one motif for the whole roster row. */
.chat-presence-avatar[data-agent="true"] {
background: var(--chat-agent-accent, var(--primary));
color: var(--primary-foreground, var(--background));
}

/* Existing reaction chips + pin toggle, indented to align under the bubble
text the same way `.chat-thread-affordance` does. Chips are content —
they always show once a reaction exists. The pin toggle fades in on
Expand Down
48 changes: 48 additions & 0 deletions packages/chat-ui/src/timeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";

import { friendlyEventText } from "./timeline";
import type { ParticipantRecord } from "./api";
import type { Part } from "./api";

function agentJoinedPart(address: string): Part & { kind: "event" } {
return {
kind: "event",
event: "workbench.agent-joined",
data: { address },
};
}

describe("friendlyEventText — workbench.agent-joined (CL-6594)", () => {
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");
});
});
11 changes: 8 additions & 3 deletions packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Loading
Loading