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 <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-20 20:04:30 +08:00
committed by GitHub
parent c5f76e0c5c
commit 748b0c98de
5 changed files with 70 additions and 16 deletions
@@ -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<NonNullable<AgentAvatarProps["size"]>, 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 (
<span
@@ -73,7 +79,7 @@ export function AgentAvatar({ name, className, size = "md" }: AgentAvatarProps)
)}
aria-hidden
>
{initials(safeName)}
{safeIcon && safeIcon !== DEFAULT_AGENT_ICON ? safeIcon : initials(safeName)}
</span>
);
}
@@ -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(
<Wrapper task={baseTask} agents={agents}>
<Wrapper task={baseTask} agents={approvalAgents}>
<ApproversPicker task={baseTask} />
</Wrapper>,
);
@@ -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(
<Wrapper task={baseTask} agents={approvalAgents}>
<ApproversPicker task={baseTask} />
</Wrapper>,
);
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(
<Wrapper task={customTask} agents={[makeAgent("a-custom", "Custom Agent", "🦾")]}>
<ApproversPicker task={customTask} />
</Wrapper>,
);
expect(screen.getByText("🦾")).toBeTruthy();
expect(screen.queryByText("CU")).toBeNull();
});
});
@@ -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<AgentItem>((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"
>
<span className="text-sm leading-none">{item.icon}</span>
<AgentAvatar name={item.name} icon={item.icon} size="xs" />
<span className="truncate max-w-[110px]">{item.name}</span>
{decisionsByAgent && <DecisionIcon decision={decisionsByAgent.get(item.id)} />}
<span
@@ -174,7 +175,7 @@ export function AgentsMultiPicker({
const renderItem = (item: AgentItem) => (
<span className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none">{item.icon}</span>
<AgentAvatar name={item.name} icon={item.icon} size="sm" />
<span className="truncate">{item.name}</span>
</span>
);
@@ -56,7 +56,7 @@ export function AssigneePicker({ task }: AssigneePickerProps) {
keywords: [a.name, a.role ?? ""],
renderLabel: () => (
<span className="flex items-center gap-2 min-w-0">
<AgentAvatar role={a.role} name={a.name} size="sm" />
<AgentAvatar role={a.role} name={a.name} icon={a.icon} size="sm" />
<span className="truncate">{a.name}</span>
</span>
),
@@ -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<string, unknown> | 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) {