From 748b0c98de38f361e2efe2eedc99d1faab037c05 Mon Sep 17 00:00:00 2001 From: nova28 Date: Thu, 20 Aug 2026 20:04:30 +0800 Subject: [PATCH] fix(office): show each agent's own avatar in the reviewer and approver chips (#2833) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- .../app/office/components/agent-avatar.tsx | 12 ++++-- .../components/agents-multi-picker.test.tsx | 42 +++++++++++++++---- .../simple/components/agents-multi-picker.tsx | 9 ++-- .../simple/components/assignee-picker.tsx | 2 +- .../e2e/tests/office/property-pickers.spec.ts | 21 ++++++++++ 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/apps/web/app/office/components/agent-avatar.tsx b/apps/web/app/office/components/agent-avatar.tsx index b9e49af0ae..d1a9130ad7 100644 --- a/apps/web/app/office/components/agent-avatar.tsx +++ b/apps/web/app/office/components/agent-avatar.tsx @@ -16,16 +16,21 @@ import { cn } from "@/lib/utils"; type AgentAvatarProps = { role?: string | null; name?: string | null; + icon?: string | null; className?: string; - size?: "sm" | "md" | "lg"; + size?: "xs" | "sm" | "md" | "lg"; }; const SIZE: Record, string> = { + xs: "h-4 w-4 text-[8px]", sm: "h-6 w-6 text-[10px]", md: "h-8 w-8 text-xs", lg: "h-10 w-10 text-sm", }; +// i18n-exempt: legacy persisted default agent icon value. +const DEFAULT_AGENT_ICON = "🤖"; + const TINTS = [ "bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-400", "bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-400", @@ -60,8 +65,9 @@ export function agentTint(name: string | null | undefined): string { return tintFor((name ?? "").trim() || "Agent"); } -export function AgentAvatar({ name, className, size = "md" }: AgentAvatarProps) { +export function AgentAvatar({ name, icon, className, size = "md" }: AgentAvatarProps) { const safeName = (name ?? "").trim() || "Agent"; + const safeIcon = icon?.trim(); const tint = tintFor(safeName); return ( - {initials(safeName)} + {safeIcon && safeIcon !== DEFAULT_AGENT_ICON ? safeIcon : initials(safeName)} ); } diff --git a/apps/web/components/task/simple/components/agents-multi-picker.test.tsx b/apps/web/components/task/simple/components/agents-multi-picker.test.tsx index fe5e96c8b0..d3f789c4e6 100644 --- a/apps/web/components/task/simple/components/agents-multi-picker.test.tsx +++ b/apps/web/components/task/simple/components/agents-multi-picker.test.tsx @@ -15,14 +15,14 @@ afterEach(() => cleanup()); const TS = "2026-05-01T00:00:00Z"; -function makeAgent(id: string, name: string): AgentProfile { +function makeAgent(id: string, name: string, icon = "🤖"): AgentProfile { return { id: toAgentProfileId(id), workspaceId: toWorkspaceId("ws-1"), name, agentProfileId: toAgentProfileId("p1"), role: "worker", - icon: "🤖", + icon, status: "idle", reportsTo: "", permissions: {}, @@ -169,15 +169,16 @@ describe("buildDecisionLookup", () => { }); }); +const approvalAgents = [ + makeAgent("a-approved", "Alice"), + makeAgent("a-changes", "Bob"), + makeAgent("a-pending", "Carol"), +]; + describe("ApproversPicker chip decoration", () => { it("renders a status icon per chip matching the most recent decision", () => { - const agents = [ - makeAgent("a-approved", "Alice"), - makeAgent("a-changes", "Bob"), - makeAgent("a-pending", "Carol"), - ]; render( - + , ); @@ -186,3 +187,28 @@ describe("ApproversPicker chip decoration", () => { expect(screen.getAllByTestId("decision-icon-pending")).toHaveLength(1); }); }); + +describe("ApproversPicker chip avatars", () => { + it("renders per-agent initials instead of the generic robot glyph", () => { + render( + + + , + ); + expect(screen.getByText("AL")).toBeTruthy(); + expect(screen.getByText("BO")).toBeTruthy(); + expect(screen.getByText("CA")).toBeTruthy(); + expect(screen.queryByText("🤖")).toBeNull(); + }); + + it("preserves a configured custom icon", () => { + const customTask = { ...baseTask, approvers: ["a-custom"], decisions: [] }; + render( + + + , + ); + expect(screen.getByText("🦾")).toBeTruthy(); + expect(screen.queryByText("CU")).toBeNull(); + }); +}); diff --git a/apps/web/components/task/simple/components/agents-multi-picker.tsx b/apps/web/components/task/simple/components/agents-multi-picker.tsx index 978328597c..89435129b0 100644 --- a/apps/web/components/task/simple/components/agents-multi-picker.tsx +++ b/apps/web/components/task/simple/components/agents-multi-picker.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import { selectOfficeAgentProfiles } from "@/lib/state/slices/office/selectors"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; +import { AgentAvatar } from "@/app/office/components/agent-avatar"; import { formatRelativeTime } from "@/lib/utils"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import type { Task, TaskDecision } from "@/app/office/tasks/[id]/types"; @@ -13,7 +14,7 @@ import { MultiSelectPopover, type MultiSelectItem } from "./multi-select-popover import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; -type AgentItem = MultiSelectItem & { icon: string; name: string }; +type AgentItem = MultiSelectItem & { icon?: string | null; name: string }; // AgentDecisionStatus is the per-chip badge state for an agent listed // as a reviewer or approver. Computed by buildDecisionLookup below. @@ -87,7 +88,7 @@ function buildAgentItems(agents: AgentProfile[]): AgentItem[] { return agents.map((a) => ({ id: a.id, name: a.name, - icon: a.icon ?? "🤖", + icon: a.icon, label: a.name, keywords: [a.name, a.role ?? ""], })); @@ -147,7 +148,7 @@ export function AgentsMultiPicker({ key={item.id} className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs" > - {item.icon} + {item.name} {decisionsByAgent && } ( - {item.icon} + {item.name} ); diff --git a/apps/web/components/task/simple/components/assignee-picker.tsx b/apps/web/components/task/simple/components/assignee-picker.tsx index 1f1752766c..6822e3abdf 100644 --- a/apps/web/components/task/simple/components/assignee-picker.tsx +++ b/apps/web/components/task/simple/components/assignee-picker.tsx @@ -56,7 +56,7 @@ export function AssigneePicker({ task }: AssigneePickerProps) { keywords: [a.name, a.role ?? ""], renderLabel: () => ( - + {a.name} ), diff --git a/apps/web/e2e/tests/office/property-pickers.spec.ts b/apps/web/e2e/tests/office/property-pickers.spec.ts index 06e5be194d..9b4c686627 100644 --- a/apps/web/e2e/tests/office/property-pickers.spec.ts +++ b/apps/web/e2e/tests/office/property-pickers.spec.ts @@ -18,6 +18,16 @@ async function gotoTaskPage(testPage: Page, taskId: string, title: string) { }); } +// Mirrors AgentAvatar's initials() in agent-avatar.tsx, so the assertion +// stays correct for whichever agent the picker actually resolved to +// (freshly created, or the seed fallback when creation fails). +function expectedInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + test.describe("property pickers", () => { test("status picker updates task status and persists", async ({ testPage, @@ -245,6 +255,8 @@ test.describe("property pickers", () => { }) .catch(() => undefined)) as Record | undefined; const reviewerId = (created?.id as string) ?? officeSeed.agentId; + // "CEO" is the fallback seed agent's name set in office-fixture.ts. + const reviewerName = created ? "Picker Reviewer Agent" : "CEO"; const task = await apiClient.createTask(officeSeed.workspaceId, "Picker Reviewer Task", { workflow_id: officeSeed.workflowId, @@ -256,6 +268,15 @@ test.describe("property pickers", () => { await trigger.click(); await testPage.getByTestId(`multi-select-add-${reviewerId}`).click(); + // The chip should render a per-agent initials avatar, not the generic + // robot glyph every agent used to share. Unconditional regardless of + // whether agent creation above succeeded, so this assertion can never + // silently no-op. + await expect(trigger.getByText(expectedInitials(reviewerName))).toBeVisible({ + timeout: 5_000, + }); + await expect(trigger.getByText("🤖")).not.toBeVisible(); + // Re-open if the popover auto-closed and remove the reviewer. const removeItem = testPage.getByTestId(`multi-select-remove-${reviewerId}`); if ((await removeItem.count()) === 0) {