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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user