feat(ui): use agent personas throughout the app

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-10 16:23:00 -05:00
parent f5cd8de1b3
commit 0b7d006dd4
87 changed files with 626 additions and 397 deletions

View File

@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".", testMatch: "agent-personas.spec.ts", workers: 1, retries: 0, timeout: 45_000,
outputDir: "./test-results/agent-personas", reporter: [["list"]],
snapshotPathTemplate: "{testDir}/.snapshots/agent-personas/{arg}{ext}",
use: {
browserName: "chromium", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017",
viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1, reducedMotion: "reduce",
launchOptions: { args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] },
},
});

View File

@ -0,0 +1,155 @@
import { test, expect, type Page } from "@playwright/test";
async function story(page: Page, id: string, args = "", theme = "dark") {
await page.goto(`/iframe.html?id=agents-personas--${id}&viewMode=story&globals=theme:${theme}${args ? `&args=${args}` : ""}`);
await page.locator("#storybook-root").waitFor();
await expect.poll(() => page.locator("#storybook-root").evaluate(element => element.childElementCount)).toBeGreaterThan(0);
}
async function imagesLoaded(page: Page) {
await page.locator("#storybook-root img").evaluateAll(images => images.forEach(image => image.setAttribute("loading", "eager")));
await expect.poll(() => page.locator("#storybook-root img").evaluateAll(images => images.every(image => (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0))).toBe(true);
}
for (const theme of ["light", "dark"]) {
for (const id of ["palettes", "sizes", "expressions", "app-placements", "gray-before-connection"]) {
test(`${id} on ${theme}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await story(page, id, "", theme); await imagesLoaded(page);
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
await expect(page.locator("canvas")).toHaveCount(0);
await expect(page.locator("#storybook-root")).toHaveScreenshot(`${id}-${theme}.png`, { animations: "disabled", maxDiffPixels: 0 });
});
}
}
test("500 avatars load images without WebGL, live modules or avatar frame loops", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.addInitScript(() => {
(window as any).__personaProbe = { webgl: 0, frames: 0 };
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (kind: string, ...args: any[]) {
if (kind.includes("webgl")) (window as any).__personaProbe.webgl++;
return (getContext as any).call(this, kind, ...args);
} as any;
const raf = window.requestAnimationFrame;
window.requestAnimationFrame = callback => {
if (/\/(runtime|renderer)-/.test(new Error().stack ?? "")) (window as any).__personaProbe.frames++;
return raf(callback);
};
});
const liveDownloads: string[] = [];
page.on("request", req => { if (/\/(runtime|renderer)-[^/]+\.js/.test(req.url())) liveDownloads.push(req.url()); });
await story(page, "five-hundred-static-avatars"); await imagesLoaded(page);
await expect(page.locator("#storybook-root img")).toHaveCount(500);
await expect(page.locator("canvas")).toHaveCount(0);
expect(await page.evaluate(() => (window as any).__personaProbe)).toEqual({ webgl: 0, frames: 0 });
expect(liveDownloads).toEqual([]);
});
test("image failures and slow cold responses preserve dimensions", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
await page.route("**/api/agent-avatars/**", async route => { await held; await route.abort(); });
await story(page, "cache-miss-loading");
const image = page.locator("#storybook-root img");
const before = await image.boundingBox();
expect(before?.width).toBe(64); expect(before?.height).toBe(64);
release();
await expect(page.locator("#storybook-root")).toContainText("CS");
const fallback = page.locator("#storybook-root span").filter({ hasText: /^CS$/ }).first();
expect((await fallback.boundingBox())?.width).toBe(64);
});
test("one live renderer and static fallback after context loss", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await story(page, "one-live-renderer");
await expect(page.locator("canvas")).toHaveCount(1);
await story(page, "render-failure");
await expect(page.locator("canvas")).toHaveCount(1);
await page.getByRole("button", { name: "Simulate WebGL loss" }).click();
await expect(page.locator("canvas")).toHaveCount(0); await imagesLoaded(page);
});
test("repeated mounting releases the WebGL canvas", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await story(page, "mount-and-unmount");
for (let i = 0; i < 3; i++) {
await expect(page.locator("canvas")).toHaveCount(1);
await page.getByRole("button", { name: "Toggle character" }).click();
await expect(page.locator("canvas")).toHaveCount(0);
await page.getByRole("button", { name: "Toggle character" }).click();
}
});
const snapshotCases = [
...[16, 24, 48, 256].flatMap(size => [1, 2].map(density => ({ size, density, state: "rest" }))),
...["idle", "listening", "thinking", "working", "success", "confused", "sleepy", "loading"].map(state => ({ size: 128, density: 2, state })),
];
for (const { size, density, state } of snapshotCases) {
test(`front-facing SVG and WebGL ${state} at ${size}px density ${density}`, async ({ browser }) => {
const context = await browser.newContext({ deviceScaleFactor: density, reducedMotion: "reduce", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017" });
const page = await context.newPage();
await story(page, "snapshot-agreement", `size:${size};state:${state};density:${density}`); await imagesLoaded(page);
await expect(page.locator("canvas")).toHaveCount(1);
await expect(page.locator("#storybook-root")).toHaveScreenshot(`snapshot-pair-${size}-${density}-${state}.png`, { scale: "device", maxDiffPixels: 0 });
// Same transparent silhouette. Small antialiasing differences are expected
// between sharp's SVG rasterizer and WebGL's multisample rasterizer.
const difference = await page.evaluate(async () => {
const image = document.querySelector('#storybook-root img') as HTMLImageElement;
const live = document.querySelector('#live-frame canvas, [data-testid="live-frame"] canvas') as HTMLCanvasElement;
const width = live.width, height = live.height;
const a = document.createElement("canvas"); a.width = width; a.height = height;
const b = document.createElement("canvas"); b.width = width; b.height = height;
const ac = a.getContext("2d")!, bc = b.getContext("2d")!;
ac.drawImage(image, 0, 0, width, height); bc.drawImage(live, 0, 0, width, height);
const aa = ac.getImageData(0, 0, width, height).data, bb = bc.getImageData(0, 0, width, height).data;
let silhouette = 0, colorError = 0, opaque = 0;
for (let i = 0; i < aa.length; i += 4) {
if ((aa[i + 3] > 128) !== (bb[i + 3] > 128)) silhouette++;
if (aa[i + 3] > 240 && bb[i + 3] > 240) { for (let c = 0; c < 3; c++) colorError += Math.abs(aa[i + c] - bb[i + c]); opaque++; }
}
return { silhouette: silhouette / (width * height), meanColorError: colorError / (opaque * 3) };
});
expect(difference.silhouette).toBeLessThan(0.04);
expect(difference.meanColorError).toBeLessThan(20);
await context.close();
});
}
const fullPages = ["all-agents", "agent-overview", "task", "company-dashboard", "meet-your-next-agent", "new-agent-connection"];
for (const id of fullPages) {
test(`full app page: ${id}`, async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.clock.setFixedTime(new Date("2026-09-10T18:00:00Z"));
await page.goto(`/iframe.html?id=agents-personas-full-pages--${id}&viewMode=story&globals=theme:dark`);
await expect(page.locator("main")).toBeVisible();
await expect(page.locator("main")).not.toContainText("This page hit an error");
await expect(page.locator('img[src*="/api/agent-avatars/"]').first()).toBeAttached();
await imagesLoaded(page);
await expect(page.locator("canvas")).toHaveCount(0);
await expect(page).toHaveScreenshot(`full-page-${id}.png`, { animations: "disabled", maxDiffPixels: 0 });
});
}
for (const density of [1, 2]) {
test(`onboarding supersamples and stays inside the canvas at density ${density}`, async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: density, reducedMotion: "no-preference", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017" });
const page = await context.newPage();
await page.goto("/iframe.html?id=agents-personas-full-pages--meet-your-next-agent&viewMode=story");
await expect(page.locator("canvas")).toHaveCount(1);
const canvas = page.locator("canvas");
const dimensions = await canvas.evaluate(c => ({ pixels: (c as HTMLCanvasElement).width, display: c.getBoundingClientRect().width }));
expect(dimensions.display).toBe(192);
expect(dimensions.pixels).toBe(dimensions.display * density * 2);
for (const [x, y] of [[5, 5], [1195, 5], [1195, 895], [5, 895]]) {
await page.mouse.move(x, y);
// Let gaze settle at each page corner, well outside the character region.
await page.waitForTimeout(350);
const edgeAlpha = await canvas.evaluate(c => {
const source = c as HTMLCanvasElement;
const copy = document.createElement("canvas"); copy.width = source.width; copy.height = source.height;
const ctx = copy.getContext("2d")!; ctx.drawImage(source, 0, 0);
const pixels = ctx.getImageData(0, 0, copy.width, copy.height).data;
let maximum = 0;
for (let y = 0; y < copy.height; y++) for (let x = 0; x < copy.width; x++) {
if (x < 2 || y < 2 || x >= copy.width - 2 || y >= copy.height - 2) maximum = Math.max(maximum, pixels[(y * copy.width + x) * 4 + 3]);
}
return maximum;
});
expect(edgeAlpha).toBe(0);
}
await context.close();
});
}

View File

@ -1,3 +1,4 @@
import type { AgentAppearance } from "@paperclipai/shared";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import type {
HeartbeatRun,
@ -31,6 +32,8 @@ export interface ActiveRunForIssue {
createdAt: string | Date;
agentId: string;
agentName: string;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;
@ -62,6 +65,8 @@ export interface LiveRunForIssue {
createdAt: string;
agentId: string;
agentName: string;
agentAppearance?: AgentAppearance | null;
avatarUrl?: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query";
@ -200,7 +201,7 @@ const AgentRunCard = memo(function AgentRunCard({
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
<AgentIdentity agent={{ id: run.agentId, name: run.agentName, appearance: run.agentAppearance }} size="sm" className="[&>span:last-child]:!text-(length:--text-micro)" />
</div>
<div className="mt-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<span>{(run.execution?.phase === "reconnecting" || run.execution?.phase === "retry_scheduled") ? "Reconnecting…" : (isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`)}</span>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useMemo, useState, useRef, useCallback, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useVisibilityRefetchInterval } from "@/lib/polling";
@ -26,7 +27,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ListFilter, Layers, ChevronDown, ChevronRight, User, Settings } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { timeAgo } from "../lib/timeAgo";
/* ------------------------------------------------------------------ */
@ -280,7 +280,7 @@ function CollapsedFeedGroup({
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
}
{group.latestEvent.actorType === "agent"
? <AgentIcon icon={actor?.icon ?? null} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
? <AgentAvatar agent={actor} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
: group.latestEvent.actorType === "user"
? <User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: <Settings className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "./AgentAvatar";
import { Link } from "@/lib/router";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { deriveInitials } from "./Identity";
@ -55,10 +56,10 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Avatar size="xs">
{event.actorType === "agent" ? <AgentAvatar agent={actor} name={actorName} size={24} /> : <Avatar size="xs">
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt={actorName} />}
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
</Avatar>
</Avatar>}
<p className="min-w-0 flex-1 truncate">
<span>{actorName}</span>
<span className="text-muted-foreground"> {verb} </span>

View File

@ -1,6 +1,6 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@ -94,7 +94,7 @@ export function AgentSelect({
setOpen(false);
}}
>
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-foreground">{agent.name}</span>
{agent.title ? <span className="truncate text-xs text-muted-foreground">{agent.title}</span> : null}
@ -261,7 +261,7 @@ export function AgentMultiSelect({
setSelection(next);
}}
/>
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="flex min-w-0 flex-col">
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="truncate">{agent.name}</span>
@ -306,7 +306,7 @@ export function AgentMultiSelect({
<div className="space-y-0.5">
{selectedAgents.slice(0, 3).map((agent) => (
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
</div>
))}

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared";
@ -96,7 +97,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
<PropertyRow label="Reports To">
{reportsToAgent ? (
<Link to={agentUrl(reportsToAgent)} className="hover:underline">
<Identity name={reportsToAgent.name} size="sm" />
<AgentIdentity agent={reportsToAgent} size="sm" />
</Link>
) : (
<span className="text-sm font-mono">{agent.reportsTo.slice(0, 8)}</span>

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { CheckCircle2, XCircle, Clock } from "lucide-react";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge";
@ -71,7 +72,7 @@ export function ApprovalCard({
{requesterAgent && (
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span>Requested by</span>
<Identity name={requesterAgent.name} size="sm" className="inline-flex" />
<AgentIdentity agent={requesterAgent} size="sm" className="inline-flex" />
</div>
)}
</div>

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, CheckCircle2 } from "lucide-react";
@ -29,6 +30,7 @@ interface BlockedInboxViewProps {
companyId: string;
searchQuery: string;
agentNameById: ReadonlyMap<string, string>;
agents?: import("./AgentAvatar").AvatarAgent[];
userLabelById?: ReadonlyMap<string, string>;
issueLinkState: unknown;
groupBy: BlockedInboxGroupBy;
@ -50,6 +52,7 @@ export function BlockedInboxView({
companyId,
searchQuery,
agentNameById,
agents,
userLabelById,
issueLinkState,
groupBy,
@ -217,7 +220,7 @@ export function BlockedInboxView({
key={row.issue.id}
row={row}
issueLinkState={issueLinkState}
agentNameById={agentNameById}
agentNameById={agentNameById} agents={agents}
userLabelById={userLabelById}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
@ -247,7 +250,7 @@ export function BlockedInboxView({
key={row.issue.id}
row={row}
issueLinkState={issueLinkState}
agentNameById={agentNameById}
agentNameById={agentNameById} agents={agents}
userLabelById={userLabelById}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
@ -272,6 +275,7 @@ interface BlockedInboxRowProps {
row: BlockedInboxIssueRow;
issueLinkState: unknown;
agentNameById: ReadonlyMap<string, string>;
agents?: import("./AgentAvatar").AvatarAgent[];
userLabelById?: ReadonlyMap<string, string>;
liveIssueIds: ReadonlySet<string>;
subtreeLiveCounts: ReadonlyMap<string, number>;
@ -301,6 +305,7 @@ function BlockedInboxRow({
row,
issueLinkState,
agentNameById,
agents,
userLabelById,
liveIssueIds,
subtreeLiveCounts,
@ -330,11 +335,7 @@ function BlockedInboxRow({
</span>
{ownerName ? (
<span className="hidden w-(--sz-150px) min-w-0 items-center text-muted-foreground sm:inline-flex">
<Identity
name={ownerName}
size="xs"
className="max-w-full"
/>
{isAgent ? <AgentIdentity agent={agents?.find((agent) => agent.id === row.attention.owner.agentId) ?? { id: row.attention.owner.agentId ?? undefined, name: ownerName }} size="xs" className="max-w-full" /> : <Identity name={ownerName} size="xs" className="max-w-full" />}
</span>
) : (
<span className="hidden w-(--sz-150px) shrink-0 sm:inline-flex" aria-hidden="true" />

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useState, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@ -408,7 +409,7 @@ export function CommandPalette() {
<span className="flex-1 truncate">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? <Identity name={name} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
return name ? <AgentIdentity agent={agents?.find((agent) => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
})()}
</CommandItem>
))}

View File

@ -1,3 +1,5 @@
import { AgentIdentity } from "./AgentIdentity";
import { AgentAvatar } from "@/components/AgentAvatar";
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link, useLocation } from "react-router-dom";
import type {
@ -18,7 +20,6 @@ import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
@ -344,8 +345,8 @@ function CommentCard({
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
<AgentIdentity
agent={agentMap?.get(comment.authorAgentId) ?? { id: comment.authorAgentId, name: comment.authorAgentId.slice(0, 8) }}
size="sm"
/>
</Link>
@ -478,9 +479,9 @@ function TimelineEventCard({
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
{event.actorType === "agent" ? <AgentAvatar agent={agentMap?.get(event.actorId) ?? { id: event.actorId, name: actorName }} size={32} className="mt-0.5" /> : (
<Avatar size="sm" className="mt-0.5"><AvatarFallback>{initialsForName(actorName)}</AvatarFallback></Avatar>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
@ -624,9 +625,7 @@ const TimelineList = memo(function TimelineList({
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
return (
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
<Avatar size="sm">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<AgentAvatar agent={agentMap?.get(run.agentId) ?? { id: run.agentId, name: actorName }} size={32} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
@ -829,6 +828,7 @@ export function CommentThread({
kind: "agent",
agentId: a.id,
agentIcon: a.icon,
agentAppearance: a.appearance,
}));
}, [agentMap, providedMentions]);
@ -1060,7 +1060,7 @@ export function CommentThread({
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>
@ -1073,7 +1073,7 @@ export function CommentThread({
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "./AgentAvatar";
import { useMemo, useRef, useState, useEffect } from "react";
import type {
DocumentAnnotationComment,
@ -23,7 +24,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, relativeTime } from "@/lib/utils";
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
import type { PendingAnchor } from "./DocumentAnnotationLayer";
@ -59,7 +59,7 @@ export interface AnnotationPanelProps {
inline?: boolean;
className?: string;
/** Resolve `<authorAgentId>` to a display name. */
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
/** Resolve `<authorUserId>` to a display name. */
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
@ -326,7 +326,7 @@ export function ThreadCard(props: {
onCopyLink: () => void;
pendingReply: boolean;
pendingStatus: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const { thread } = props;
@ -461,7 +461,7 @@ function CommentRow({
}: {
comment: DocumentAnnotationComment;
focused: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
@ -476,18 +476,11 @@ function CommentRow({
>
<div className="mb-0.5 flex items-center justify-between gap-2 text-(length:--text-micro)">
<span className="flex min-w-0 items-center gap-1.5">
{author.role === "agent" ? <AgentAvatar agent={author.agent} size={20} /> : (
<Avatar size="xs" className="shrink-0">
{author.role === "agent" ? (
<AvatarFallback>
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
</AvatarFallback>
) : (
<>
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
</>
)}
</Avatar>
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
</Avatar>)}
<span className="truncate font-medium text-foreground">{author.name}</span>
{author.role === "agent" ? (
<span className="text-muted-foreground">· agent</span>
@ -508,16 +501,17 @@ function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): bool
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; agent?: import("./AgentAvatar").AvatarAgent; imageUrl?: string | null } {
if (comment.authorAgentId) {
const agent = maps.agentMap?.get(comment.authorAgentId);
return {
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
role: "agent",
agentIcon: agent?.icon,
agent: agent ?? { id: comment.authorAgentId },
};
}
if (comment.authorUserId) {

View File

@ -1,3 +1,4 @@
import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
import type { ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { cn, relativeTime } from "../lib/utils";
@ -13,13 +14,13 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
export type DocumentFrameHeaderRevisionActor = {
kind: "agent" | "user" | "system";
name: string;
agentIcon?: string | null;
agent?: AvatarAgent;
imageUrl?: string | null;
};
@ -57,18 +58,11 @@ export interface DocumentFrameHeaderProps {
}
function RevisionActorAvatar({ actor }: { actor: DocumentFrameHeaderRevisionActor }) {
if (actor.kind === "agent") return <AgentAvatar agent={actor.agent} name={actor.name} size={20} />;
return (
<Avatar size="xs" shape={actor.kind === "agent" ? "square" : "circle"} className="shrink-0">
{actor.kind === "agent" ? (
<AvatarFallback>
<AgentIcon icon={actor.agentIcon} className="h-3 w-3" />
</AvatarFallback>
) : (
<>
{actor.imageUrl ? <AvatarImage src={actor.imageUrl} alt={actor.name} /> : null}
<AvatarFallback>{deriveInitials(actor.name)}</AvatarFallback>
</>
)}
<Avatar size="xs" className="shrink-0">
{actor.imageUrl ? <AvatarImage src={actor.imageUrl} alt={actor.name} /> : null}
<AvatarFallback>{deriveInitials(actor.name)}</AvatarFallback>
</Avatar>
);
}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useMemo, useState } from "react";
import type { Agent, Issue } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
@ -13,7 +14,6 @@ import {
import { cn } from "../lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Eye, ShieldCheck } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
type StageType = "review" | "approval";
@ -189,7 +189,7 @@ export function ExecutionParticipantPicker({
)}
onClick={() => toggle(encoded)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
{agent.name}
</button>
);

View File

@ -1,5 +1,5 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { Link } from "@/lib/router";
import { AgentIcon } from "./AgentIconPicker";
import { timeAgo } from "../lib/timeAgo";
import { cn } from "../lib/utils";
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
@ -380,10 +380,8 @@ function resolveContent(
function ActorGlyph({ content }: { content: CardContent }) {
if (content.actorType === "agent") {
return (
<AgentIcon
icon={content.actor?.icon ?? null}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<AgentAvatar agent={content.actor} size={16}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
);
}
if (content.actorType === "user") {

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import type {
ReasoningMessagePart,
@ -145,7 +146,6 @@ import {
type InlineEntityOption,
} from "./InlineEntitySelector";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { AgentIcon } from "./AgentIconPicker";
import {
AssigneeChip,
ComposerHandoffPreviewRow,
@ -1287,8 +1287,8 @@ function IssueChatChainOfThought({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2.5">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{agentIcon ? (
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
{agentId ? (
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
) : isActive ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : (
@ -2417,17 +2417,7 @@ function IssueChatAssistantMessage({
!isRunning &&
(hasCommentText || deleted);
const agentAvatar = (
<Avatar size="sm" className="shrink-0">
{agentIcon ? (
<AvatarFallback>
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(authorName)}</AvatarFallback>
)}
</Avatar>
);
const agentAvatar = <AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId, name: authorName } : { name: authorName }} size={32} />;
const messageActionBar = (
<div className="mt-2 flex items-center gap-1">
@ -2553,8 +2543,8 @@ function IssueChatAssistantMessage({
{/* Icon + name together in a header ABOVE the bubble (PAP-95 rev 7). */}
<div className="mb-1 flex items-center gap-1.5 px-1">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{agentIcon ? (
<AgentIcon icon={agentIcon} className="h-4 w-4" />
{agentId ? (
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
) : (
<Avatar size="sm" className="size-5">
<AvatarFallback className="text-(length:--text-nano)">
@ -2706,11 +2696,8 @@ function IssueChatAssistantMessage({
<div className="rounded-lg px-1 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{agentIcon ? (
<AgentIcon
icon={agentIcon}
className="h-4 w-4 shrink-0"
/>
{agentId ? (
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
) : (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
)}
@ -3080,15 +3067,11 @@ function ExpiredRequestConfirmationActivity({
</div>
) : (
<div className="flex items-start gap-2.5 py-1">
<Avatar size="sm" className="mt-0.5">
{actorIcon ? (
<AvatarFallback>
<AgentIcon icon={actorIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
)}
</Avatar>
{actorAgentId ? (
<AgentAvatar agent={agentMap?.get(actorAgentId) ?? { id: actorAgentId, name: actorName }} size={32} />
) : (
<Avatar size="sm" className="mt-0.5"><AvatarFallback>{initialsForName(actorName)}</AvatarFallback></Avatar>
)}
{rowContent}
</div>
)}
@ -3800,15 +3783,11 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
if (custom.kind === "event" && actorName) {
const isAgent = actorType === "agent";
const agentIcon =
isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
const isCurrentUser =
actorType === "user" && !!currentUserId && actorId === currentUserId;
const rowIcon = agentIcon ? (
<AgentIcon icon={agentIcon} className="h-3 w-3" />
) : (
<ClipboardList className="h-3 w-3" />
);
const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
const rowIcon = isAgent
? <AgentAvatar agent={actorId ? agentMap?.get(actorId) ?? { id: actorId } : undefined} size={16} />
: <ClipboardList className="h-3 w-3" />;
const handoffResolvers: HandoffChipResolvers = {
agentMap,
currentUserId,
@ -3903,18 +3882,8 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
? (agentMap?.get(runAgentId)?.name ?? runAgentId.slice(0, 8))
: null);
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
if (
custom.kind === "run" &&
runId &&
runAgentId &&
displayedRunAgentName &&
runStatus
) {
const rowIcon = runAgentIcon ? (
<AgentIcon icon={runAgentIcon} className="h-3 w-3" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
);
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
const rowIcon = <AgentAvatar agent={agentMap?.get(runAgentId) ?? { id: runAgentId }} size={16} />;
return (
<IssueChatMetadataRow anchorId={anchorId} icon={rowIcon}>
@ -5585,10 +5554,7 @@ const IssueChatComposer = forwardRef<
return (
<>
{agent ? (
<AgentIcon
icon={agent.icon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>
@ -5604,10 +5570,7 @@ const IssueChatComposer = forwardRef<
return (
<>
{agent ? (
<AgentIcon
icon={agent.icon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>

View File

@ -134,7 +134,7 @@ describe("InboxIssueMetaLeading live state", () => {
});
describe("InboxIssueTrailingColumns attribution", () => {
it("renders a kicked off by column for agent creators with square identity", () => {
it("renders a kicked off by column for agent creators with a character identity", () => {
const text = renderLeading(
<InboxIssueTrailingColumns
issue={makeIssue({
@ -155,7 +155,7 @@ describe("InboxIssueTrailingColumns attribution", () => {
);
expect(text).toContain("CodexCoder");
expect(container?.querySelector('[data-shape="square"]')).not.toBeNull();
expect(container?.querySelector('[data-slot="agent-avatar"]')).not.toBeNull();
});
it("renders a kicked off by column for user creators", () => {
@ -208,7 +208,7 @@ describe("InboxIssueTrailingColumns attribution", () => {
// The responsible user wins over the creating agent.
expect(text).toContain("Morgan Product");
expect(container?.querySelector('[data-shape="circle"]')).not.toBeNull();
expect(container?.querySelector('[data-shape="square"]')).toBeNull();
expect(container?.querySelector('[data-slot="agent-avatar"]')).toBeNull();
});
it("surfaces the responsible user for a routine execution with no creator", () => {

View File

@ -1,3 +1,5 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import type { AvatarAgent } from "./AgentAvatar";
import type { ReactNode } from "react";
import { deriveOriginatingActor, type Issue } from "@paperclipai/shared";
import { Columns3 } from "lucide-react";
@ -271,6 +273,8 @@ export function InboxIssueTrailingColumns({
workspaceId,
workspaceName,
assigneeName,
assigneeAgent,
creatorAgent,
assigneeUserName,
assigneeUserAvatarUrl,
creatorAgentName,
@ -290,6 +294,8 @@ export function InboxIssueTrailingColumns({
workspaceId?: string | null;
workspaceName: string | null;
assigneeName: string | null;
assigneeAgent?: AvatarAgent;
creatorAgent?: AvatarAgent;
assigneeUserName?: string | null;
assigneeUserAvatarUrl?: string | null;
creatorAgentName?: string | null;
@ -322,10 +328,9 @@ export function InboxIssueTrailingColumns({
if (issue.assigneeAgentId) {
return (
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
<AgentIdentity
agent={assigneeAgent ?? { id: issue.assigneeAgentId, name: assigneeName ?? issue.assigneeAgentId.slice(0, 8) }}
size="sm"
shape="square"
className="min-w-0"
/>
</span>
@ -359,10 +364,9 @@ export function InboxIssueTrailingColumns({
<Tooltip key={column}>
<TooltipTrigger asChild>
<span className="min-w-0 text-xs text-foreground">
<Identity
name={name}
<AgentIdentity
agent={creatorAgent ?? { id: originatingActor.id, name }}
size="sm"
shape="square"
className="min-w-0"
/>
</span>

View File

@ -648,7 +648,7 @@ describe("IssueDocumentAnnotations", () => {
expect(expandedText).toContain("UXDesigner");
expect(expandedText).toContain("· agent");
// Each rendered comment shows an author avatar.
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"], [data-slot="agent-avatar"]') ?? [];
expect(avatars.length).toBe(2);
});

View File

@ -589,7 +589,7 @@ describe("IssueDocumentsSection", () => {
expect(document.body.textContent).toContain("CodexCoder");
expect(document.body.textContent).toContain("Dotta");
expect(document.body.textContent).not.toContain("• agent");
expect(document.body.querySelectorAll('[data-slot="avatar"]').length).toBeGreaterThanOrEqual(2);
expect(document.body.querySelectorAll('[data-slot="avatar"], [data-slot="agent-avatar"]').length).toBeGreaterThanOrEqual(2);
await act(async () => {
root.unmount();

View File

@ -149,7 +149,7 @@ function downloadDocumentFile(key: string, body: string) {
function getRevisionActor(
revision: DocumentRevision,
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): DocumentFrameHeaderRevisionActor {
@ -159,6 +159,7 @@ function getRevisionActor(
kind: "agent",
name: agent?.name ?? revision.createdByAgentId.slice(0, 8),
agentIcon: agent?.icon ?? null,
agent: agent ?? { id: revision.createdByAgentId },
};
}
if (revision.createdByUserId) {
@ -275,7 +276,7 @@ export function IssueDocumentsSection({
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
extraActions?: ReactNode;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/**
* Seed which document annotation panels are open on first render. Mostly useful

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
@ -2310,6 +2311,8 @@ function StreamlinedIssuesList({
})}
onFilterWorkspace={filterToWorkspace}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={assigneeUserLabel}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
creatorAgentName={agentName(issue.createdByAgentId)}
@ -2333,7 +2336,7 @@ function StreamlinedIssuesList({
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" shape="square" className="min-w-0" />
<AgentIdentity agent={agents!.find((agent) => agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
<Identity
name={assigneeUserLabel ?? "User"}
@ -2412,7 +2415,7 @@ function StreamlinedIssuesList({
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
<AgentIdentity agent={agent} size="sm" className="min-w-0" />
</button>
))}
</div>

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import {
@ -367,7 +368,7 @@ function KanbanCard({
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="xs" />
<AgentIdentity agent={agents?.find((agent) => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="xs" />
) : (
<span className="text-xs text-muted-foreground font-mono">
{issue.assigneeAgentId.slice(0, 8)}

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import type { ReactNode } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
@ -2203,6 +2204,8 @@ export function IssuesList({
})}
onFilterWorkspace={filterToWorkspace}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={assigneeUserLabel}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
creatorAgentName={agentName(issue.createdByAgentId)}
@ -2226,7 +2229,7 @@ export function IssuesList({
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" shape="square" className="min-w-0" />
<AgentIdentity agent={agents!.find((agent) => agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
<Identity
name={assigneeUserLabel ?? "User"}
@ -2305,7 +2308,7 @@ export function IssuesList({
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
<AgentIdentity agent={agent} size="sm" className="min-w-0" />
</button>
))}
</div>

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useMemo, useState } from "react";
import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -113,7 +114,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link to={`/agents/${run.agentId}`} className="inline-flex hover:underline">
<Identity name={run.agentName} size="sm" />
<AgentIdentity agent={{ id: run.agentId, name: run.agentName, appearance: run.agentAppearance }} size="sm" />
</Link>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Link

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "./AgentAvatar";
import {
Component,
type ClipboardEvent,
@ -45,7 +46,6 @@ import {
buildUserMentionHref,
} from "@paperclipai/shared";
import { Boxes, CalendarClock, Flag, Hash, User, X } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
@ -68,6 +68,7 @@ export interface MentionOption {
kind?: "agent" | "project" | "user" | "issue";
agentId?: string;
agentIcon?: string | null;
agentAppearance?: import("@paperclipai/shared").AgentAppearance | null;
projectId?: string;
projectColor?: string | null;
userId?: string;
@ -1596,10 +1597,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
) : option.kind === "user" ? (
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<AgentIcon
icon={option.agentIcon}
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
<AgentAvatar agent={{ id: option.agentId ?? option.id, name: option.name, appearance: option.agentAppearance }} size={16} />
)}
{option.kind === "issue" && option.issueIdentifier ? (
<span className="flex min-w-0 items-baseline gap-1.5">

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@ -77,7 +78,6 @@ import { extractProviderIdWithFallback } from "../lib/model-utils";
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { AgentIcon } from "./AgentIconPicker";
import { InlineBanner } from "./InlineBanner";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getTrustPreset } from "../lib/trust-policy-ui";
@ -1507,7 +1507,7 @@ export function NewIssueDialog() {
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -1524,7 +1524,7 @@ export function NewIssueDialog() {
: null;
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{assignee ? <AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
{assignee && getTrustPreset(assignee.permissions) === "low_trust_review" ? (
<ShieldAlert className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" aria-label="Low-trust review agent" />
@ -1665,7 +1665,7 @@ export function NewIssueDialog() {
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
return reviewer ? <AgentAvatar agent={reviewer} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null;
})()}
<span className="truncate">{option.label}</span>
</>
@ -1680,7 +1680,7 @@ export function NewIssueDialog() {
: null;
return (
<>
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{reviewer ? <AgentAvatar agent={reviewer} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -1710,7 +1710,7 @@ export function NewIssueDialog() {
const approver = parseAssigneeValue(option.id).assigneeAgentId
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
: null;
return approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
return approver ? <AgentAvatar agent={approver} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null;
})()}
<span className="truncate">{option.label}</span>
</>
@ -1725,7 +1725,7 @@ export function NewIssueDialog() {
: null;
return (
<>
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{approver ? <AgentAvatar agent={approver} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -1747,7 +1747,7 @@ export function NewIssueDialog() {
>
{selectedWatchdogAgent ? (
<>
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={selectedWatchdogAgent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate text-foreground">{selectedWatchdogAgent.name}</span>
{watchdogInstructions.trim() ? (
<span className="truncate text-muted-foreground">· {watchdogInstructions.trim()}</span>
@ -1773,7 +1773,7 @@ export function NewIssueDialog() {
option ? (
<>
{selectedWatchdogAgent ? (
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={selectedWatchdogAgent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>
@ -1785,7 +1785,7 @@ export function NewIssueDialog() {
const agent = (agents ?? []).find((a) => a.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@ -1,5 +1,7 @@
import { storeProviderApiKey } from "../lib/provider-credential";
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared";
import { AgentCharacter } from "./AgentCharacter";
import { useEffect, useState, useMemo, useRef } from "react";
import type { ComponentType, CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -550,6 +552,7 @@ function OnboardingWizardInner({
// on the customer's behalf that they then have to notice and undo. It is the
// step's only question, and its CTA gates on it.
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "");
const [agentAppearance, setAgentAppearance] = useState(() => agentAppearanceSchema.safeParse(saved?.agentAppearance).data ?? randomAgentAppearance());
// Defaults to `general` rather than empty. The arc stopped asking for a role
// — a customer naming their first agent is describing what it does, not
// filing it — but the hire still needs one, and the guard below returns
@ -764,6 +767,7 @@ function OnboardingWizardInner({
* hand rather than the one before it.
*/
function clearCompanyScopedState() {
setAgentAppearance(randomAgentAppearance());
setCreatedCompanyPrefix(null);
setCompanyName("");
setCreatedCompanyGoalId(null);
@ -867,7 +871,7 @@ function OnboardingWizardInner({
if (!effectiveOnboardingOpen) return;
const state = {
step, companyName,
agentName, agentRole, adapterType, cwd, model, command, args, url,
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
// The mode, never the key: this blob is localStorage.
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
@ -876,7 +880,7 @@ function OnboardingWizardInner({
onboardingDraftStorage.write(JSON.stringify(state));
}, [
effectiveOnboardingOpen, step, companyName,
agentName, agentRole, adapterType, cwd, model, command, args, url,
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
credentialMode, credentialModeChoice,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
@ -1515,6 +1519,7 @@ function OnboardingWizardInner({
// Back to the mount defaults: an empty name (the step's only question, and
// what its CTA gates on) and the neutral role every onboarding hire uses.
setAgentName("");
setAgentAppearance(randomAgentAppearance());
setAgentRole(DEFAULT_AGENT_ROLE);
setAdapterType("claude_local");
setModel("");
@ -2050,6 +2055,7 @@ function OnboardingWizardInner({
if (existing) {
if (!stillTheSameCompany(createdCompanyId)) return;
setCreatedAgentId(existing.id);
setAgentAppearance(resolveAgentAppearance(existing.appearance, existing.id));
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
@ -2061,6 +2067,7 @@ function OnboardingWizardInner({
// The name is optional; an agent that reaches here without one is
// named for the job it was hired to do rather than left blank.
name: hireName,
appearance: agentAppearance,
role: agentRole,
adapterType,
adapterConfig: hireAdapterConfig,
@ -2335,7 +2342,7 @@ function OnboardingWizardInner({
/>
)}
{/* The hero, above the heading: one PillGuy held in the same tree
{/* The hero, above the heading: one character held in the same tree
slot across steps 35, so React reuses the DOM node and moving
between steps never replays the entrance. It is dormant while
the agent is being specified and wakes on Review. */}
@ -2368,14 +2375,7 @@ function OnboardingWizardInner({
to this box and travel out past its top-right
corner. */}
<div className="relative size-(--sz-72px)">
<PillGuy
state={step === 5 ? "alive" : "dormant"}
className="size-full"
/>
{/* Only while it is actually asleep. A still grey
silhouette reads as a placeholder that failed to
load rather than as something waiting its turn. */}
{step < 5 && <SleepingZs />}
<AgentCharacter appearance={agentAppearance} size={128} state={step === 5 ? "success" : adapterEnvLoading || loading || ["loading", "waiting", "connecting"].includes(connectPhase) ? "loading" : "sleepy"} muted={step < 5} className="size-full" />
</div>
<AgentPreview agentName={agentName} agentRole="" />
</motion.div>

View File

@ -38,7 +38,7 @@ type CaseBodyDocument = PipelineCaseDocumentPayload["document"] & {
function getPipelineRevisionActor(
revision: { createdByAgentId?: string | null; createdByUserId?: string | null },
maps: {
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
},
): DocumentFrameHeaderRevisionActor {
@ -48,6 +48,7 @@ function getPipelineRevisionActor(
kind: "agent",
name: agent?.name ?? revision.createdByAgentId.slice(0, 8),
agentIcon: agent?.icon ?? null,
agent: agent ?? { id: revision.createdByAgentId },
};
}
@ -76,7 +77,7 @@ export interface PipelineItemBodyDocumentProps {
/** Active conversation issue the body document is/should be anchored to. */
conversationIssueId: string | null;
conversationIssue: Issue | null;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
mentions?: MentionOption[];
imageUploadHandler?: (file: File) => Promise<string>;

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
@ -8,7 +9,6 @@ import {
import { User } from "lucide-react";
import { cn } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
import { AgentIcon } from "./AgentIconPicker";
export function ReportsToPicker({
agents,
@ -55,7 +55,7 @@ export function ReportsToPicker({
</>
) : current ? (
<>
<AgentIcon icon={current.icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
<AgentAvatar agent={current} size={16} className="h-3 w-3 shrink-0 text-muted-foreground"/>
<span
className={cn(
"min-w-0 truncate",
@ -91,7 +91,7 @@ export function ReportsToPicker({
</button>
{terminatedManager && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
<AgentIcon icon={current.icon} className="shrink-0 h-3 w-3" />
<AgentAvatar agent={current} size={16} className="shrink-0 h-3 w-3"/>
<span className="min-w-0 truncate">
Current: {current.name} (terminated)
</span>
@ -115,7 +115,7 @@ export function ReportsToPicker({
setOpen(false);
}}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<AgentAvatar agent={a} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
<span className="min-w-0 truncate">{a.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
</button>

View File

@ -1,7 +1,7 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import type { ReactNode } from "react";
import { MoreHorizontal, Play } from "lucide-react";
import { Link } from "@/lib/router";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -148,7 +148,7 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
</span>
<span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
{agent?.icon ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0"/> : null}
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
</span>
<span>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
@ -12,7 +13,6 @@ import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
@ -374,7 +374,7 @@ export function RoutineRunVariablesDialog({
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -389,7 +389,7 @@ export function RoutineRunVariablesDialog({
const assignee = agents.find((agent) => agent.id === option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{assignee ? <AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -43,7 +44,6 @@ import {
type AgentSidebarSortMode,
writeAgentSortMode,
} from "../lib/agent-order";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarNavItem } from "./SidebarNavItem.production";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
@ -166,7 +166,7 @@ function SidebarAgentItem({
<SidebarNavItem
to={href}
label={agent.name}
iconNode={<AgentIcon icon={agent.icon} className="shrink-0 h-4 w-4" />}
iconNode={<AgentAvatar agent={agent} size={16} className="shrink-0 h-4 w-4"/>}
active={isActive}
liveCount={runCount}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -43,7 +44,6 @@ import {
type AgentSidebarSortMode,
writeAgentSortMode,
} from "../lib/agent-order";
import { AgentIcon } from "./AgentIconPicker";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
@ -166,7 +166,7 @@ function SidebarAgentItem({
<SidebarNavItem
to={href}
label={agent.name}
iconNode={<AgentIcon icon={agent.icon} className="shrink-0 h-4 w-4" />}
iconNode={<AgentAvatar agent={agent} size={16} className="shrink-0 h-4 w-4"/>}
active={isActive}
liveCount={runCount}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}

View File

@ -3,7 +3,7 @@ import type { CompanySecret, RoutineEnvConfig } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { EmptyState } from "./EmptyState";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { AgentIcon } from "./AgentIconPicker";
import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
export interface StageSecretsPanelProps {
/** Whether the stage has a backing automation routine with an assignee. */
@ -11,6 +11,7 @@ export interface StageSecretsPanelProps {
/** Display name + icon of the agent that runs this step (when automation exists). */
agentName?: string | null;
agentIcon?: string | null;
agent?: AvatarAgent;
/** Company secret inventory (shared, not stage-scoped). */
secrets: CompanySecret[];
secretsLoading: boolean;
@ -35,6 +36,7 @@ export function StageSecretsPanel({
hasAutomation,
agentName,
agentIcon,
agent,
secrets,
secretsLoading,
value,
@ -65,7 +67,7 @@ export function StageSecretsPanel({
<div className="space-y-5">
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
{agentName ? (
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<AgentAvatar agent={agent} name={displayName} size={16} />
) : (
<KeyRound className="h-3.5 w-3.5 mt-0.5 shrink-0" />
)}

View File

@ -1556,6 +1556,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
agentName:
meta?.agentName ??
(meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined),
agent: meta?.agentId ? agentMap?.get(meta.agentId) ?? { id: meta.agentId } : undefined,
agentIcon: meta?.agentId
? agentMap?.get(meta.agentId)?.icon
: undefined,
@ -1764,7 +1765,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
agentName:
meta?.agentName ??
(meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined),
agentIcon: meta?.agentId
agent: meta?.agentId ? agentMap?.get(meta.agentId) ?? { id: meta.agentId } : undefined,
agentIcon: meta?.agentId
? agentMap?.get(meta.agentId)?.icon
: undefined,
standaloneHeader: sourceIsPaperclipRunner,
@ -1854,6 +1856,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
(liveRun.agentId
? agentMap?.get(liveRun.agentId)?.name
: undefined),
agent: liveRun.agentId ? agentMap?.get(liveRun.agentId) ?? { id: liveRun.agentId } : undefined,
agentIcon: liveRun.agentId
? agentMap?.get(liveRun.agentId)?.icon
: undefined,
@ -2767,6 +2770,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
agentName={visibleTailAgentName}
agentIcon={visibleTailAgentIcon}
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
items={tailItems}
status={
optimisticRunnerStartup

View File

@ -1,6 +1,6 @@
import { AlertTriangle, Info, PauseCircle, User, X } from "lucide-react";
import { cn } from "../../lib/utils";
import { AgentIcon } from "../AgentIconPicker";
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
import {
classifyAssigneeHandoff,
resolveRunStatusPresentation,
@ -17,7 +17,7 @@ import {
* so they can be exercised in isolation by component tests and Storybook.
*/
export interface HandoffAgentLike {
export interface HandoffAgentLike extends AvatarAgent {
name: string;
icon?: string | null;
}
@ -60,7 +60,7 @@ export function AssigneeChip({
return (
<span className={cn(CHIP_CLASS, className)} data-testid="handoff-assignee-chip" data-kind="agent">
<span className="sr-only">Agent </span>
<AgentIcon icon={agentIcon(assignee.agentId, resolvers)} className="h-3 w-3 shrink-0 text-muted-foreground" />
<AgentAvatar agent={{ ...resolvers.agentMap?.get(assignee.agentId), id: assignee.agentId }} size={16} />
<span className="max-w-(--sz-12rem) truncate">{agentName(assignee.agentId, resolvers)}</span>
</span>
);

View File

@ -1,3 +1,5 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { AgentAvatar } from "@/components/AgentAvatar";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
@ -78,7 +80,6 @@ import {
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
import { User, ArrowUpRight, Plus, X, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore, ChevronLeft } from "lucide-react";
import { AgentIcon } from "../AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector";
import {
AssigneeRunningBanner,
@ -949,7 +950,7 @@ export function IssueProperties({
// --- Interrupt-handoff clarity for the assignee picker (design surface 2) ---
const handoffResolvers: HandoffChipResolvers = useMemo(
() => ({
agentMap: new Map((agents ?? []).map((agent) => [agent.id, { name: agent.name, icon: agent.icon }])),
agentMap: new Map((agents ?? []).map((agent) => [agent.id, agent])),
resolveUserLabel: (id) => userLabel(id),
}),
// userLabel closes over userLabelMap + currentUserId, both reflected here.
@ -1141,7 +1142,7 @@ export function IssueProperties({
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 text-sm" title={issue.watchdog.instructions?.trim() || undefined}>
{(() => {
const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId);
return agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
return agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null;
})()}
<span className="shrink-0 max-w-40 truncate">{agentName(issue.watchdog.watchdogAgentId)}</span>
{issue.watchdog.instructions?.trim() ? (
@ -1185,7 +1186,7 @@ export function IssueProperties({
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3 w-3 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3 w-3 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -1194,7 +1195,7 @@ export function IssueProperties({
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3 w-3 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3 w-3 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -1691,7 +1692,7 @@ export function IssueProperties({
);
const assigneeTrigger = assignee ? (
<Identity name={assignee.name} size="sm" shape="square" />
<AgentIdentity agent={assignee} size="sm" />
) : assigneeUserLabel ? (
<>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@ -1771,7 +1772,7 @@ export function IssueProperties({
}}
>
{option.kind === "agent" ? (
<AgentIcon icon={option.agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<AgentAvatar agent={option.agent} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
) : option.kind === "user" ? (
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
) : null}
@ -1930,7 +1931,7 @@ export function IssueProperties({
)}
onClick={() => toggleExecutionParticipant(stageType, encoded)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
{agent.name}
</button>
);
@ -2873,11 +2874,7 @@ export function IssueProperties({
to={`/agents/${originatingActor.id}`}
className="hover:underline"
>
<Identity
name={agentName(originatingActor.id) ?? originatingActor.id.slice(0, 8)}
size="sm"
shape="square"
/>
<AgentIdentity agent={agents?.find((agent) => agent.id === originatingActor.id) ?? { id: originatingActor.id, name: agentName(originatingActor.id) ?? "Agent" }} size="sm" />
</Link>
) : (
<span className="flex min-w-0 items-center gap-1.5">
@ -2941,7 +2938,7 @@ export function IssueProperties({
title={`Archived by ${archivedByName} · ${formatDateTime(issue.archivedAt)}`}
>
{archivedByAgent
? <AgentIcon icon={archivedByAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
? <AgentAvatar agent={archivedByAgent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
: null}
<span className="min-w-0 truncate">
{archivedByName}

View File

@ -1,3 +1,4 @@
import { AgentCharacter } from "../AgentCharacter";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Check, ChevronRight } from "lucide-react";
@ -16,7 +17,6 @@ import {
DialogDescription,
DialogTitle,
} from "../ui/dialog";
import { PillGuy } from "../onboarding/PillGuy";
export type AgentBasics = {
name: string;
@ -166,7 +166,7 @@ export function AgentBasicsDialog({
>
<div className="flex min-h-0 flex-col gap-7 overflow-y-auto px-6 pb-8 sm:px-10">
<div className="flex flex-col items-center gap-4 text-center">
<PillGuy state="dormant" className="size-16" />
<AgentCharacter state="sleepy" muted size={256} className="size-48" trackingScope="page" />
<div className="space-y-2">
<DialogTitle className="text-3xl font-semibold tracking-tight">
{step === "name"

View File

@ -1,3 +1,5 @@
import { AgentCharacter } from "../AgentCharacter";
import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
import {
SETUP_CREDENTIAL_KEYS,
@ -49,7 +51,6 @@ import { Field } from "../agent-config-primitives";
import { SecretPicker } from "../environment-variables-editor/SecretPicker";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { PillGuy } from "../onboarding/PillGuy";
import {
OnboardingCard,
OnboardingHeading,
@ -105,6 +106,7 @@ function Setup({
const navigate = useNavigate();
const cache = useQueryClient();
const { openNewIssue } = useDialogActions();
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
const isRunner = adapterType === "paperclip_runner";
const brandType = isRunner
? runnerProvider === "claude"
@ -487,6 +489,7 @@ function Setup({
);
const response = await agentsApi.hire(companyId, {
name: name.trim(),
appearance: appearanceDraft.appearance,
role: existing.length ? "general" : "ceo",
...(leader ? { reportsTo: leader.id } : {}),
adapterType,
@ -507,6 +510,7 @@ function Setup({
setApiKey("");
setConnection(null);
setCreated(response.agent);
appearanceDraft.clear();
setScreen("saved");
navigate(
`/agents/new?${new URLSearchParams({ name: response.agent.name, adapterType, runnerProvider, createdAgentId: response.agent.id })}`,
@ -614,10 +618,9 @@ function Setup({
<MotionConfig reducedMotion="user">
<div className="mx-auto flex max-w-5xl flex-col gap-8 py-6">
<header className="flex items-center gap-4">
<PillGuy
state={created ? "alive" : "dormant"}
className="size-14 shrink-0"
/>
<AgentCharacter appearance={created?.appearance ?? appearanceDraft.appearance} size={256} className="size-48" trackingScope="page"
state={created || testState === "pass" ? "success" : testState === "running" ? "loading" : "sleepy"}
muted={!created && testState !== "pass"} />
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">{name}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -1,3 +1,4 @@
import { AgentCharacter } from "../AgentCharacter";
import { useState } from "react";
import { MotionConfig } from "motion/react";
@ -96,7 +97,7 @@ export function ConnectModelPreview({
{/* `relative` is load-bearing: the sleep marks anchor to this box and
travel out past its top-right corner. */}
<div className="relative size-(--sz-72px)">
<PillGuy state="dormant" className="size-full" />
<AgentCharacter state="sleepy" muted size={128} className="size-full" />
<SleepingZs />
</div>
<AgentPreview agentName="Darnold" agentRole="" />

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
@ -28,7 +29,6 @@ import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
@ -177,7 +177,7 @@ export function OverviewSection({
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -193,7 +193,7 @@ export function OverviewSection({
return (
<>
{assignee ? (
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
@ -27,7 +28,6 @@ import { timeAgo } from "../../lib/timeAgo";
import { EmptyState } from "../EmptyState";
import { InlineEntitySelector } from "../InlineEntitySelector";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations";
import { AgentIcon } from "../AgentIconPicker";
import { MarkdownEditor } from "../MarkdownEditor";
import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor";
@ -175,7 +175,7 @@ export function OverviewSection({
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -191,7 +191,7 @@ export function OverviewSection({
return (
<>
{assignee ? (
<AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
) : null}
<span className="truncate">{option.label}</span>
</>

View File

@ -1,3 +1,5 @@
import { AgentAvatar } from "../AgentAvatar";
import { AgentIdentity } from "../AgentIdentity";
import { memo, type ComponentType, type SVGProps } from "react";
import { Bot, FileText, Hexagon, MessageSquare, Paperclip, Quote } from "lucide-react";
import type { Agent, CompanySearchResult } from "@paperclipai/shared";
@ -46,7 +48,7 @@ function formatRelativeTime(input: string | null): string {
export interface SearchResultRowProps {
result: CompanySearchResult;
agentsById?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentsById?: ReadonlyMap<string, Pick<Agent, "id" | "name" | "appearance">>;
isActive?: boolean;
className?: string;
}
@ -67,9 +69,7 @@ function SearchResultRowImpl({
className={cn(ROW_BASE, "py-3", isActive && "bg-muted/40", className)}
data-result-type="agent"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Bot className="h-3 w-3" />
</span>
<AgentAvatar agent={agentsById?.get(result.id) ?? { id: result.id, name: result.title }} size={24} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{result.title}</span>
@ -214,7 +214,7 @@ function SearchResultRowImpl({
<div className="ml-2 hidden shrink-0 flex-col items-end gap-2 sm:flex">
{assigneeName || updated ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{assigneeName ? <Identity name={assigneeName} size="sm" /> : null}
{assigneeName ? <AgentIdentity agent={agentsById?.get(result.issue?.assigneeAgentId ?? "") ?? { id: result.issue?.assigneeAgentId ?? undefined, name: assigneeName }} size="sm" /> : null}
{updated ? <span className="tabular-nums">{updated}</span> : null}
</div>
) : null}

View File

@ -1,3 +1,4 @@
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
import { useContext, useState, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
@ -9,7 +10,6 @@ import {
type GalleryMediaItem,
} from "@/components/ImageGalleryModal";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import { CommentAttributionChip } from "@/components/CommentAttributionChip";
import {
Attachment,
@ -74,10 +74,12 @@ function initialsForName(name: string) {
export function TaskChatAgentIdentity({
agentName,
agentIcon,
agent,
onBehalfOfUserName,
}: {
agentName: string;
agentIcon?: string | null;
agent?: AvatarAgent;
onBehalfOfUserName?: string;
}) {
return (
@ -85,19 +87,7 @@ export function TaskChatAgentIdentity({
className="flex items-center gap-2 px-1"
data-testid="task-chat-agent-identity"
>
<Avatar
size="sm"
className="shrink-0"
data-testid="task-chat-agent-avatar"
>
{agentIcon ? (
<AvatarFallback>
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(agentName)}</AvatarFallback>
)}
</Avatar>
<span data-testid="task-chat-agent-avatar"><AgentAvatar agent={agent} name={agentName} size={24} /></span>
<span className="text-sm font-semibold text-foreground">{agentName}</span>
{onBehalfOfUserName ? (
<CommentAttributionChip
@ -232,7 +222,7 @@ export function TaskChatBubble({
{!isHuman && item.authorName && !hideAgentIdentity ? (
<TaskChatAgentIdentity
agentName={item.authorName}
agentIcon={item.agentIcon}
agentIcon={item.agentIcon} agent={item.agent}
onBehalfOfUserName={item.onBehalfOfUserName}
/>
) : null}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "../AgentAvatar";
import {
useEffect,
useRef,
@ -64,7 +65,6 @@ import {
InlineEntitySelector,
type InlineEntityOption,
} from "@/components/InlineEntitySelector";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { MentionOption } from "@/components/MarkdownEditor";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
@ -117,7 +117,7 @@ interface TaskChatComposerProps {
mentions?: MentionOption[];
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
agentMap?: ReadonlyMap<string, { icon?: string | null }>;
agentMap?: ReadonlyMap<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }>;
userProfileMap?: ReadonlyMap<
string,
{ label: string; image: string | null }
@ -239,7 +239,7 @@ function AssigneeIdentityAvatar({
}: {
assigneeValue: string;
label: string;
agentMap: ReadonlyMap<string, { icon?: string | null }> | undefined;
agentMap: ReadonlyMap<string, import("../AgentAvatar").AvatarAgent & { icon?: string | null }> | undefined;
userProfileMap:
| ReadonlyMap<string, { label: string; image: string | null }>
| null
@ -250,8 +250,7 @@ function AssigneeIdentityAvatar({
const agentId = assigneeValue.slice("agent:".length);
const icon = agentMap?.get(agentId)?.icon ?? "bot";
return (
<Avatar
size="xs"
<span
className="shrink-0"
data-assignee-identity={assigneeValue}
data-assignee-trigger-icon={placement === "trigger" ? icon : undefined}
@ -259,10 +258,8 @@ function AssigneeIdentityAvatar({
placement === "option" ? assigneeValue : undefined
}
>
<AvatarFallback>
<AgentIcon icon={icon} className="h-3 w-3" />
</AvatarFallback>
</Avatar>
<AgentAvatar agent={agentMap?.get(agentId) ?? { id: agentId }} size={16} />
</span>
);
}

View File

@ -80,7 +80,7 @@ describe("TaskChatDescriptionBubble (PAP-375)", () => {
expect(bubble).not.toBeNull();
expect(bubble?.getAttribute("data-author")).toBe("human");
expect(bubble?.className).toContain("items-end");
expect(bubble?.querySelector('[data-testid="task-chat-agent-avatar"]')).toBeNull();
expect(bubble?.querySelector('[data-slot="agent-avatar"]')).toBeNull();
const body = bubble?.querySelector(".bg-\\(--liveness-blue\\)");
expect(body).not.toBeNull();
expect(body?.textContent).toContain("Ship the widget by");
@ -94,7 +94,7 @@ describe("TaskChatDescriptionBubble (PAP-375)", () => {
const bubble = container.querySelector('[data-testid="task-chat-description-bubble"]');
expect(bubble?.getAttribute("data-author")).toBe("agent");
expect(bubble?.className).toContain("items-start");
expect(bubble?.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull();
expect(bubble?.querySelector('[data-slot="agent-avatar"]')).not.toBeNull();
expect(bubble?.textContent).toContain("CEO");
expect(bubble?.querySelector(".bg-\\(--bubble-agent\\)")).not.toBeNull();
expect(bubble?.querySelector(".bg-\\(--liveness-blue\\)")).toBeNull();

View File

@ -1,3 +1,4 @@
import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
import { useState } from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
@ -5,7 +6,6 @@ import { FoldCurtain } from "@/components/FoldCurtain";
import { InlineEditor } from "@/components/InlineEditor";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import type { MentionOption } from "@/components/MarkdownEditor";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
@ -23,6 +23,7 @@ export interface TaskChatIssueBrief {
authorName?: string;
/** Creating agent's assigned icon (AgentIconName) for the avatar header. */
agentIcon?: string | null;
agent?: AvatarAgent;
/** issue.createdAt — rendered like the other bubbles' footer timestamps. */
createdAt?: string | Date;
onSave: (description: string) => void | Promise<unknown>;
@ -115,15 +116,7 @@ export function TaskChatDescriptionBubble({ brief }: TaskChatDescriptionBubblePr
>
{!isHuman && brief.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{brief.agentIcon ? (
<AvatarFallback>
<AgentIcon icon={brief.agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(brief.authorName)}</AvatarFallback>
)}
</Avatar>
<AgentAvatar agent={brief.agent} name={brief.authorName} size={24} />
<span className="text-sm font-semibold text-foreground">{brief.authorName}</span>
</span>
) : null}

View File

@ -429,6 +429,7 @@ export function TaskChatRunnerTurn({
runId,
agentName,
agentIcon,
agent,
items,
status,
execution,
@ -443,6 +444,7 @@ export function TaskChatRunnerTurn({
runId?: string | null;
agentName?: string | null;
agentIcon?: string | null;
agent?: import("../AgentAvatar").AvatarAgent;
items: readonly TaskChatItem[];
status: string;
execution?: ExecutionProjection | null;
@ -526,7 +528,7 @@ export function TaskChatRunnerTurn({
data-testid="task-chat-runner-identity-row"
>
{agentName ? (
<TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} />
<TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} agent={agent} />
) : null}
<RunnerTurnStatus
status={status}
@ -602,7 +604,7 @@ export function TaskChatRunnerTurn({
data-testid="task-chat-final-response"
>
<TaskChatBubble
item={{ ...final, authorName: agentName ?? undefined, agentIcon, timestamp: final.timestamp ?? formatTaskChatTimestamp(final.atMs) }}
item={{ ...final, authorName: agentName ?? undefined, agentIcon, agent, timestamp: final.timestamp ?? formatTaskChatTimestamp(final.atMs) }}
animateEntry={false}
hideAgentIdentity={!continuedAfterSteering}
actions={<TaskChatBubbleActions copyText={final.text} />}

View File

@ -102,6 +102,7 @@ function renderItem(
...item.attachedTurn,
agentName: item.attachedTurn.agentName ?? item.authorName,
agentIcon: item.attachedTurn.agentIcon ?? item.agentIcon,
agent: item.attachedTurn.agent ?? item.agent,
}
: item.attachedTurn;
const turn = attachedTurnItem ? (

View File

@ -96,7 +96,7 @@ export function TaskChatTurn({
{item.agentName ? (
<TaskChatAgentIdentity
agentName={item.agentName}
agentIcon={item.agentIcon}
agentIcon={item.agentIcon} agent={item.agent}
/>
) : null}
<span className="min-w-0 truncate">

View File

@ -123,6 +123,7 @@ export function commentsToTaskChatItems(
kind: "message",
author: kind,
authorName,
agent: effectiveAgentId(comment) ? ctx.agentMap?.get(effectiveAgentId(comment)!) ?? { id: effectiveAgentId(comment)! } : undefined,
text: comment.body,
timestamp: formatTaskChatCommentTimestamp(comment, kind),
optimistic,

View File

@ -124,6 +124,7 @@ export interface TaskChatMessageItem {
}>;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
agent?: import("../AgentAvatar").AvatarAgent;
/**
* Responsible user's display name, set only when this agent comment is a
* cross-issue write (the author is not the assignee). Renders as a
@ -502,6 +503,7 @@ export interface TaskChatTurnItem {
/** Agent identity retained when a live runner turn becomes durable history. */
agentName?: string;
agentIcon?: string | null;
agent?: import("../AgentAvatar").AvatarAgent;
/**
* The in-flight run's status line, hoisted to be THE turn's single visible
* row while collapsed (PAP-354 parent-row model). Absent once settled.

View File

@ -178,13 +178,15 @@ describe("WorkTimelineChart", () => {
expect(lastCall?.toMs).toBeCloseTo(new Date("2026-07-02T01:00:00.000Z").getTime(), -3);
});
it("renders configured agent icons in the actor gutter instead of generated initials", () => {
it("renders cached persona images in the actor gutter", () => {
renderChart(timelineSample());
const gutter = container.querySelector<SVGSVGElement>("[data-testid='work-timeline-actor-gutter']");
expect(gutter?.querySelector(".lucide-code")).not.toBeNull();
expect(gutter?.querySelector(".lucide-shield")).not.toBeNull();
const portraits = gutter?.querySelectorAll('image[data-testid="timeline-agent-icon"]');
expect(portraits?.length).toBe(2);
expect(portraits?.[0].getAttribute("href")).toContain("/api/agent-avatars/cap-v1/");
expect(portraits?.[1].getAttribute("href")).toContain("/api/agent-avatars/cap-v1/");
expect(gutter?.textContent).not.toContain("CC");
});

View File

@ -1,3 +1,4 @@
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
/**
* Work Timeline custom-SVG Gantt (board-locked Direction C, PAP-12422).
*
@ -12,7 +13,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
import { getAgentIcon } from "@/lib/agent-icons";
import {
AXIS_H,
actorType,
@ -182,19 +182,10 @@ function ActorGlyph({
clipId: string;
}) {
if (actor.type === "agent") {
const Icon = getAgentIcon(actor.avatar);
const size = r > 10 ? 16 : 13;
return (
<Icon
data-testid="timeline-agent-icon"
x={cx - size / 2}
y={cy - size / 2}
width={size}
height={size}
strokeWidth={2.2}
color="var(--color-muted-foreground)"
/>
);
const size = r > 10 ? 24 : 16;
const appearance = resolveAgentAppearance(actor.appearance, actor.id.replace(/^agent:/, ""));
return <image data-testid="timeline-agent-icon" href={agentAvatarUrl(appearance, size, 2)}
x={cx - size / 2} y={cy - size / 2} width={size} height={size} preserveAspectRatio="xMidYMid meet" />;
}
const stroke = "var(--color-foreground)";

View File

@ -27,7 +27,7 @@ import {
type ModelSource,
} from "./components/onboarding/ModelSourceTiles";
import { OnboardingHeading } from "./components/onboarding/OnboardingPrimitives";
import { PillGuy } from "./components/onboarding/PillGuy";
import { AgentCharacter } from "./components/AgentCharacter";
import { SleepingZs } from "./components/onboarding/SleepingZs";
import { Stepper } from "./components/onboarding/Stepper";
import "./index.css";
@ -299,7 +299,7 @@ function ConnectFlowPreview({
<div className="flex flex-col items-center">
<div className="relative size-(--sz-72px)">
<PillGuy state={done ? "alive" : "dormant"} className="size-full" />
<AgentCharacter muted={!done} state={done ? "success" : "sleepy"} size={128} className="size-full" />
{!done && <SleepingZs />}
</div>
<AgentPreview agentName="Ron" agentRole="" />

View File

@ -52,7 +52,6 @@ import { ApiError } from "@/api/client";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
import { AgentIcon } from "@/components/AgentIconPicker";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
import { Button } from "@/components/ui/button";

View File

@ -85,7 +85,7 @@ export function buildCompanyUserMentionOptions(
}
export function isAgentTaskTarget(
agent: Pick<Agent, "status"> & Partial<Pick<Agent, "orgChainHealth">>,
agent: Pick<Agent, "status"> & Partial<Pick<Agent, "orgChainHealth" | "appearance">>,
): boolean {
return (
agent.status !== "terminated" &&
@ -116,7 +116,7 @@ export function buildIssueMentionOptions(
}
export function buildMarkdownMentionOptions(args: {
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon"> & Partial<Pick<Agent, "orgChainHealth">>> | null | undefined;
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon"> & Partial<Pick<Agent, "orgChainHealth" | "appearance">>> | null | undefined;
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
members?: CompanyUserRecord[] | null | undefined;
issues?: Array<Pick<Issue, "id" | "identifier" | "title">> | null | undefined;
@ -132,6 +132,7 @@ export function buildMarkdownMentionOptions(args: {
kind: "agent" as const,
agentId: agent.id,
agentIcon: agent.icon,
agentAppearance: agent.appearance,
})),
...[...(args.projects ?? [])]
.sort((left, right) => left.name.localeCompare(right.name))

View File

@ -1,3 +1,5 @@
import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
@ -89,7 +91,6 @@ import {
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { AgentToolsTab } from "./AgentToolsTab";
import { AgentChannelsPanel } from "../components/chat/AgentChannelsPanel";
@ -1280,14 +1281,7 @@ export function AgentDetail() {
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<AgentIconPicker
value={agent.icon}
onChange={(icon) => updateIcon.mutate(icon)}
>
<button className="shrink-0 flex items-center justify-center h-12 w-12 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
<AgentIcon icon={agent.icon} className="h-6 w-6" />
</button>
</AgentIconPicker>
<AgentCharacter agent={agent} state={characterStateForAgent(agent.status)} size={96} trackingScope="page" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold truncate">{agent.name}</h2>

View File

@ -1,3 +1,5 @@
import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router";
import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
@ -25,7 +27,6 @@ import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { PillGuy } from "../components/onboarding/PillGuy";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@ -87,7 +88,6 @@ import {
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { AgentToolsTab } from "./AgentToolsTab";
import { AgentChannelsPanel } from "../components/chat/AgentChannelsPanel";
@ -1245,7 +1245,7 @@ export function AgentDetail() {
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-border pb-6">
<div className="flex min-w-0 items-center gap-4">
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0">
<PillGuy state="alive" className="size-12" />
<AgentCharacter agent={agent} state={characterStateForAgent(agent.status)} size={96} trackingScope="page" />
</div>
<div className="min-w-0 space-y-1">
<h1 className="truncate text-2xl font-semibold tracking-tight">{agent.name}</h1>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@ -402,7 +403,7 @@ export function Agents() {
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={agent.status} />
<AgentAvatar agent={agent} size={32} />
)}
secondaryRow={
builtInCluster ? (
@ -655,7 +656,7 @@ function OrgTreeNode({
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={node.status} />
<AgentAvatar agent={agent ?? node} size={24} />
)}
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-2">
{/* Name floor + `truncate` keeps the primary identifier readable; the

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@ -398,7 +399,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
leading={hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={agent.status} />
<AgentAvatar agent={agent} size={32} />
)}
secondaryRow={builtInCluster && (
<div className="@5xl:hidden flex flex-wrap items-center gap-1.5">
@ -633,7 +634,7 @@ function OrgTreeNode({
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : (
<AgentStatusCapsule status={node.status} />
<AgentAvatar agent={agent ?? node} size={24} />
)}
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-2">
{/* Name floor + `truncate` keeps the primary identifier readable; the

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -213,8 +214,8 @@ export function ApprovalDetail() {
{approval.requestedByAgentId && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Requested by</span>
<Identity
name={agentNameById.get(approval.requestedByAgentId) ?? approval.requestedByAgentId.slice(0, 8)}
<AgentIdentity
agent={agents?.find((agent) => agent.id === approval.requestedByAgentId) ?? { id: approval.requestedByAgentId, name: "Agent" }}
size="sm"
/>
</div>
@ -331,8 +332,8 @@ export function ApprovalDetail() {
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentNameById.get(comment.authorAgentId) ?? comment.authorAgentId.slice(0, 8)}
<AgentIdentity
agent={agents?.find((agent) => agent.id === comment.authorAgentId) ?? { id: comment.authorAgentId, name: "Agent" }}
size="sm"
/>
</Link>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import {
useEffect,
useLayoutEffect,
@ -29,7 +30,6 @@ import {
AgentBubbleActionRow,
agentBubbleDateLabel,
} from "../components/AgentBubbleActionRow";
import { AgentIcon } from "../components/AgentIconPicker";
import { cn, formatDateTime } from "../lib/utils";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
@ -63,21 +63,8 @@ function agentInitials(name: string): string {
* Icon-adjacent-to-name header rendered directly above an agent bubble
* the shared `[agent icon][agent name]` convention (PAP-105 / PAP-97).
*/
function AgentBubbleHeader({ name, icon }: { name: string; icon: string | null }) {
return (
<div className="mb-1 flex items-center gap-1.5 pl-1">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>
{icon ? (
<AgentIcon icon={icon} className="h-3.5 w-3.5" />
) : (
agentInitials(name)
)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium text-foreground">{name}</span>
</div>
);
function AgentBubbleHeader({ agent }: { agent: import("../components/AgentAvatar").AvatarAgent }) {
return <div className="mb-1 flex items-center gap-1.5 pl-1"><AgentAvatar agent={agent} size={24} /><span className="text-sm font-medium text-foreground">{agent.name}</span></div>;
}
/** Agent-styled chat bubble containing the three-dot typing indicator. */
@ -776,7 +763,7 @@ export function BoardChat() {
return (
<>
<div className="flex flex-col items-start">
<AgentBubbleHeader name={ceoName} icon={ceoAgent.icon} />
<AgentBubbleHeader agent={{ ...ceoAgent, name: ceoName }} />
<div
className={cn(
boardChatBubbleShell,
@ -833,7 +820,7 @@ export function BoardChat() {
const agentIconValue = agent?.icon ?? null;
return (
<div key={comment.id} className="flex flex-col items-start">
<AgentBubbleHeader name={agentName} icon={agentIconValue} />
<AgentBubbleHeader agent={agent ?? { id: comment.authorAgentId ?? undefined, name: agentName }} />
<div
className={cn(
boardChatBubbleShell,
@ -883,7 +870,7 @@ export function BoardChat() {
{streamingText && (
<div className="flex flex-col items-start">
{ceoAgent && (
<AgentBubbleHeader name={ceoAgent.name} icon={ceoAgent.icon} />
<AgentBubbleHeader agent={ceoAgent} />
)}
<div
className={cn(

View File

@ -1,3 +1,5 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useRef, useState, type SVGProps } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -35,7 +37,6 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
import { PageSkeleton } from "../components/PageSkeleton";
import { CopyText } from "../components/CopyText";
import { Identity } from "../components/Identity";
import { AgentIcon } from "../components/AgentIconPicker";
import { AgentMultiSelect } from "../components/AgentMultiSelect";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import {
@ -3242,7 +3243,7 @@ export function SkillDetailPage({
const meta = attachAgentMetaById.get(agent.id);
return (
<div key={agent.id} className="flex items-center gap-3 border-b border-border py-3 text-sm last:border-b-0">
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={meta} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{agent.name}</span>
@ -3450,7 +3451,7 @@ export function SkillDetailPage({
to={`/agents/${agent.urlKey}/skills`}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline hover:bg-accent/40"
>
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={meta} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
{meta?.paused ? (
<Pause className="h-3 w-3 shrink-0 text-amber-500" aria-label="Paused" />
@ -3910,7 +3911,7 @@ function SkillPane({
to={`/agents/${agent.urlKey}/skills`}
className="group rounded-md border border-transparent p-2 no-underline hover:border-border hover:bg-accent/40"
>
<Identity name={agent.name} size="sm" />
<AgentIdentity agent={agent} size="sm" />
</Link>
))}
</div>

View File

@ -1,3 +1,5 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useRef, useState, type SVGProps } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -35,7 +37,6 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
import { PageSkeleton } from "../components/PageSkeleton";
import { CopyText } from "../components/CopyText";
import { Identity } from "../components/Identity";
import { AgentIcon } from "../components/AgentIconPicker";
import { AgentMultiSelect } from "../components/AgentMultiSelect";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
@ -3259,7 +3260,7 @@ export function SkillDetailPage({
const meta = attachAgentMetaById.get(agent.id);
return (
<div key={agent.id} className="flex items-center gap-3 border-b border-border py-3 text-sm last:border-b-0">
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={meta} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{agent.name}</span>
@ -3467,7 +3468,7 @@ export function SkillDetailPage({
to={`/agents/${agent.urlKey}/skills`}
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline hover:bg-accent/40"
>
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={meta} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
{meta?.paused ? (
<Pause className="h-3 w-3 shrink-0 text-amber-500" aria-label="Paused" />
@ -3928,7 +3929,7 @@ function SkillPane({
to={`/agents/${agent.urlKey}/skills`}
className="group rounded-md border border-transparent p-2 no-underline hover:border-border hover:bg-accent/40"
>
<Identity name={agent.name} size="sm" />
<AgentIdentity agent={agent} size="sm" />
</Link>
))}
</div>

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@ -742,7 +743,7 @@ export function Costs() {
) : (
<span className="h-3 w-3 shrink-0" />
)}
<Identity name={row.agentName ?? row.agentId} size="sm" />
<AgentIdentity agent={{ id: row.agentId, name: row.agentName ?? row.agentId, appearance: row.agentAppearance }} size="sm" />
{row.agentStatus === "terminated" ? <StatusBadge status="terminated" /> : null}
</div>
<div className="text-right text-sm tabular-nums">

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@ -770,7 +771,7 @@ export function Costs({
) : (
<span className="h-3 w-3 shrink-0" />
)}
<Identity name={row.agentName ?? row.agentId} size="sm" />
<AgentIdentity agent={{ id: row.agentId, name: row.agentName ?? row.agentId, appearance: row.agentAppearance }} size="sm" />
{row.agentStatus === "terminated" ? <StatusBadge status="terminated" /> : null}
</div>
<div className="text-right text-sm tabular-nums">

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "../components/AgentIdentity";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import {
@ -538,7 +539,7 @@ export function Dashboard() {
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
? <span className="hidden sm:inline-flex"><AgentIdentity agent={agents?.find(agent => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId ?? undefined, name }} size="sm" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>

View File

@ -2693,7 +2693,7 @@ function StreamlinedInbox() {
<BlockedInboxView
companyId={selectedCompanyId!}
searchQuery={searchQuery}
agentNameById={agentById}
agentNameById={agentById} agents={agents}
userLabelById={companyUserLabelMap}
issueLinkState={issueLinkState}
groupBy={blockedGroupBy}
@ -2906,6 +2906,8 @@ function StreamlinedInbox() {
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
?? assigneeUserProfile?.label

View File

@ -1,3 +1,5 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentIdentity } from "@/components/AgentIdentity";
import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
import {
memo,
@ -187,7 +189,6 @@ import {
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueFieldChangeReceipt } from "../components/IssueFieldChangeReceipt";
import { IssueWriteDenialNotice } from "../components/IssueWriteDenialNotice";
@ -655,7 +656,7 @@ function ActorIdentity({
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
return <AgentIdentity agent={agent ?? { id, name: id.slice(0, 8) }} size="sm" />;
}
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
if (evt.actorType === "user") {
@ -672,6 +673,7 @@ function ActorIdentity({
}
export type AttributionActor = {
appearance?: Agent["appearance"];
kind: "agent" | "user";
id: string;
name: string;
@ -702,36 +704,26 @@ function AttributionAvatar({
return (
<Tooltip>
<TooltipTrigger asChild>
<Avatar
size="xs"
shape={actor.kind === "agent" ? "square" : "circle"}
aria-label={accessibleLabel}
data-testid={`issue-${testIdLabel}-avatar`}
className="ring-2 ring-background"
>
{actor.avatarUrl ? (
<AvatarImage src={actor.avatarUrl} alt="" />
) : null}
<AvatarFallback>{attributionInitials(actor.name)}</AvatarFallback>
</Avatar>
<span aria-label={accessibleLabel} data-testid={`issue-${testIdLabel}-avatar`}>
{actor.kind === "agent" ? <AgentAvatar agent={actor} size={20} /> : (
<Avatar size="xs" className="ring-2 ring-background">
{actor.avatarUrl ? <AvatarImage src={actor.avatarUrl} alt="" /> : null}
<AvatarFallback>{attributionInitials(actor.name)}</AvatarFallback>
</Avatar>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="px-2 py-1.5">
<div
className="flex items-center gap-2"
data-testid={`issue-${testIdLabel}-tooltip`}
>
<Avatar
size="sm"
shape={actor.kind === "agent" ? "square" : "circle"}
className="ring-1 ring-background/30"
>
{actor.avatarUrl ? (
<AvatarImage src={actor.avatarUrl} alt="" />
) : null}
<AvatarFallback className="bg-background/20 text-background">
{attributionInitials(actor.name)}
</AvatarFallback>
</Avatar>
{actor.kind === "agent" ? <AgentAvatar agent={actor} size={32} /> : (
<Avatar size="sm" className="ring-1 ring-background/30">
{actor.avatarUrl ? <AvatarImage src={actor.avatarUrl} alt="" /> : null}
<AvatarFallback>{attributionInitials(actor.name)}</AvatarFallback>
</Avatar>
)}
<div className="min-w-0">
<div className="text-(length:--text-nano) font-medium uppercase leading-none text-background/70">
{label}
@ -769,6 +761,7 @@ function IssueAttributionByline({
? {
kind: "agent",
id: issue.assigneeAgentId,
appearance: agentMap.get(issue.assigneeAgentId)?.appearance,
name:
agentMap.get(issue.assigneeAgentId)?.name ??
issue.assigneeAgentId.slice(0, 8),
@ -790,6 +783,7 @@ function IssueAttributionByline({
? {
kind: "agent",
id: originatingActor.id,
appearance: agentMap.get(originatingActor.id)?.appearance,
name:
agentMap.get(originatingActor.id)?.name ??
originatingActor.id.slice(0, 8),
@ -7698,7 +7692,8 @@ export function IssueDetail() {
? (agentMap.get(issue.createdByAgentId)?.name ??
"Agent")
: undefined,
agentIcon: issue.createdByAgentId
agent: issue.createdByAgentId ? agentMap.get(issue.createdByAgentId) ?? { id: issue.createdByAgentId } : undefined,
agentIcon: issue.createdByAgentId
? agentMap.get(issue.createdByAgentId)?.icon
: undefined,
createdAt: issue.createdAt,

View File

@ -2772,6 +2772,8 @@ export function Inbox() {
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
?? assigneeUserProfile?.label

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@ -10,7 +11,6 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { useCloudInstance } from "@/hooks/useCloudInstance";
@ -598,7 +598,7 @@ export function OrgChart() {
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentIcon icon={agent?.icon} className="h-4.5 w-4.5 text-foreground/70" />
<AgentAvatar agent={agent} size={16} className="h-4.5 w-4.5 text-foreground/70"/>
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
@ -10,7 +11,6 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { useCloudInstance } from "@/hooks/useCloudInstance";
@ -630,7 +630,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentIcon icon={agent?.icon} className="h-4.5 w-4.5 text-foreground/70" />
<AgentAvatar agent={agent} size={16} className="h-4.5 w-4.5 text-foreground/70"/>
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -61,7 +62,6 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
import { PipelineStageHistoryPanel } from "../components/PipelineStageHistoryPanel";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { Button } from "@/components/ui/button";
import {
@ -2825,7 +2825,7 @@ export function PipelineSettings() {
const agent = option.id.startsWith("agent:") ? agentById.get(option.id.slice("agent:".length)) : null;
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -2835,7 +2835,7 @@ export function PipelineSettings() {
const agent = option.id.startsWith("agent:") ? agentById.get(option.id.slice("agent:".length)) : null;
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -2916,7 +2916,7 @@ export function PipelineSettings() {
: null;
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -2927,7 +2927,7 @@ export function PipelineSettings() {
const agent = agentId ? agentById.get(agentId) : null;
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);
@ -3056,7 +3056,7 @@ export function PipelineSettings() {
) : null}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AgentIcon icon={selectedAutomationAgent.icon} className="h-4 w-4 shrink-0" />
<AgentAvatar agent={selectedAutomationAgent} size={16} className="h-4 w-4 shrink-0"/>
<span>{selectedAutomationAgent.name} runs this step automatically.</span>
</div>
<FieldRow label="Issue title">
@ -3143,6 +3143,7 @@ export function PipelineSettings() {
hasAutomation={Boolean(detail.routineId && detail.assigneeAgentId)}
agentName={automationAgent?.name ?? null}
agentIcon={automationAgent?.icon ?? null}
agent={automationAgent ?? undefined}
secrets={secretsQuery.data ?? []}
secretsLoading={secretsQuery.isLoading}
value={stageEnv}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { groupWarningsByStage, LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
@ -84,7 +85,6 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { EmptyState } from "../components/EmptyState";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueChatThread } from "../components/IssueChatThread";
import { MarkdownBody } from "../components/MarkdownBody";
import { PageSkeleton } from "../components/PageSkeleton";
@ -1363,7 +1363,7 @@ function PipelineBoardColumn({
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs font-medium text-muted-foreground hover:text-foreground"
title={`Edit ${stage.name} automation`}
>
<AgentIcon icon={automationAgent.icon} className="h-3.5 w-3.5 shrink-0" />
<AgentAvatar agent={automationAgent} size={16} className="h-3.5 w-3.5 shrink-0"/>
<span className="truncate">{automationAgent.name}</span>
</Link>
) : null}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
@ -25,7 +26,6 @@ import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
@ -1042,7 +1042,7 @@ export function Routines() {
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -1057,7 +1057,7 @@ export function Routines() {
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{assignee ? <AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, Navigate, useNavigate, useSearchParams } from "@/lib/router";
@ -24,7 +25,6 @@ import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
@ -1075,7 +1075,7 @@ export function Routines() {
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="truncate">{option.label}</span>
</>
) : (
@ -1090,7 +1090,7 @@ export function Routines() {
const assignee = agentById.get(option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{assignee ? <AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@ -368,8 +368,8 @@ export function Search() {
placeholderData: (previousData) => previousData,
});
const agentsById = useMemo<ReadonlyMap<string, Pick<Agent, "id" | "name">>>(() => {
const map = new Map<string, Pick<Agent, "id" | "name">>();
const agentsById = useMemo<ReadonlyMap<string, Pick<Agent, "id" | "name" | "appearance">>>(() => {
const map = new Map<string, Pick<Agent, "id" | "name" | "appearance">>();
for (const agent of agents) map.set(agent.id, agent);
return map;
}, [agents]);
@ -774,7 +774,7 @@ interface SearchTabContentProps {
sortLabel: string;
zeroResultsSlot: ReactNode;
isFetching: boolean;
agentsById: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
agentsById: ReadonlyMap<string, Pick<Agent, "id" | "name" | "appearance">>;
}
function SearchTabContent({

View File

@ -1,3 +1,4 @@
import { AgentIdentity } from "@/components/AgentIdentity";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -2955,7 +2956,7 @@ function AgentPicker({
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
{selectedAgent ? (
<Identity name={selectedAgent.name} size="xs" />
<AgentIdentity agent={selectedAgent} size="xs" />
) : (
<span className="text-muted-foreground">Pick an agent</span>
)}
@ -2988,7 +2989,7 @@ function AgentPicker({
)}
aria-hidden
/>
<Identity name={agent.name} size="xs" />
<AgentIdentity agent={agent} size="xs" />
{!selectable && (
<Badge variant="secondary" className="ml-auto">
Paused
@ -3115,7 +3116,7 @@ function RunDetailView({
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={runBadgeStatus(detail.status)} />
<Identity name={agentName} size="xs" />
<AgentIdentity agent={agent ?? { id: detail.agentId, name: agentName }} size="xs" />
{removed && <Badge variant="secondary">removed</Badge>}
<span className="font-mono text-xs text-muted-foreground">
v{detail.skillVersion.revisionNumber}

View File

@ -1,8 +1,8 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
import { AgentIcon } from "@/components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { isAgentTaskTarget } from "@/lib/company-members";
import { queryKeys } from "@/lib/queryKeys";
@ -48,7 +48,7 @@ export function SummarizerAgentSelect({
const agent = option.id.startsWith("agent:") ? agentById.get(option.id.slice("agent:".length)) : null;
return (
<>
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
<span className="truncate">{option.label}</span>
</>
);

View File

@ -1,8 +1,8 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useRef, useState } from "react";
import { Ban, Check, FlaskConical, Loader2, RefreshCw, Search, ShieldQuestion } from "lucide-react";
import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paperclipai/shared";
import { useSearchParams } from "@/lib/router";
import { AgentIcon } from "@/components/AgentIconPicker";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
import { Button } from "@/components/ui/button";
@ -180,7 +180,7 @@ function AgentAccessSection({
<div className="space-y-0.5">
{selectedAgents.map((agent) => (
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
</div>
))}

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Download, ScrollText, ShieldAlert } from "lucide-react";
@ -15,7 +16,6 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Identity } from "@/components/Identity";
import { AgentIcon } from "@/components/AgentIconPicker";
import { cn, relativeTime } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import { formatActivityVerb } from "@/lib/activity-format";
@ -112,7 +112,7 @@ function AuditActor({
return (
<span className="inline-flex min-w-0 items-center gap-1.5" title={agent.name}>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-3 w-3" />
<AgentAvatar agent={agent} size={16} className="h-3 w-3"/>
</span>
<span className="truncate font-medium text-foreground">{agent.name}</span>
</span>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Download, ScrollText, ShieldAlert } from "lucide-react";
@ -15,7 +16,6 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Identity } from "@/components/Identity";
import { AgentIcon } from "@/components/AgentIconPicker";
import { cn, relativeTime } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import { formatActivityVerb } from "@/lib/activity-format";
@ -122,7 +122,7 @@ function AuditActor({
return (
<span className="inline-flex min-w-0 items-center gap-1.5" title={agent.name}>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-3 w-3" />
<AgentAvatar agent={agent} size={16} className="h-3 w-3"/>
</span>
<span className="truncate font-medium text-foreground">{agent.name}</span>
</span>

View File

@ -1,3 +1,4 @@
import { AgentAvatar } from "@/components/AgentAvatar";
import { useCallback, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, Fingerprint, KeyRound, Link2, ShieldAlert, Variable, ServerCog } from "lucide-react";
@ -6,7 +7,6 @@ import type {
SecretProposalAgentRef,
SecretProposalView,
} from "@paperclipai/shared";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -126,7 +126,7 @@ export function AgentRefChip({
}) {
return (
<span className={cn("inline-flex min-w-0 items-center gap-1", className)}>
<AgentIcon icon={agent.icon ?? null} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
<span className="min-w-0 truncate">{agent.name}</span>
</span>
);

View File

@ -0,0 +1,115 @@
import { useEffect, useState, useRef } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useQueryClient } from "@tanstack/react-query";
import { Route, Routes, useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Layout } from "@/components/Layout";
import { Agents } from "@/pages/Agents";
import { AgentDetail } from "@/pages/AgentDetail";
import { IssueDetail } from "@/pages/IssueDetail";
import { Dashboard } from "@/pages/Dashboard";
import { NewAgent } from "@/pages/NewAgent";
import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog";
import { queryKeys } from "@/lib/queryKeys";
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { storybookAgents, storybookIssues, storybookActivityEvents, storybookLiveRuns, storybookDashboardSummary } from "../fixtures/paperclipData";
const companyId = "company-storybook";
const agents = storybookAgents.map(agent => {
const appearance = resolveAgentAppearance(agent.appearance, agent.id);
return { ...agent, appearance, avatarUrl: agentAvatarUrl(appearance), chainOfCommand: [], access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] } };
});
const issue = { ...storybookIssues[0], assigneeAgentId: agents[0].id, status: "in_progress" };
/** Real route components and navigation; all domain data stays in Storybook. */
function installPageFixtures() {
const previous = window.fetch;
window.fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
const path = url.pathname;
if (!path.startsWith("/api/") || path.startsWith("/api/agent-avatars/")) return previous(input, init);
const json = (data: unknown) => Response.json(data);
if (path === "/api/cli-auth/me") return json({ source: "local_implicit", isInstanceAdmin: true, companyIds: [companyId], memberships: [] });
if (path === "/api/health") return json({ status: "ok", deploymentMode: "local_trusted", authReady: true, bootstrapStatus: "ready" });
if (path === "/api/instance/settings") return json({ experimental: {} });
if (path === "/api/instance/settings/experimental") return previous(input, init);
if (path.endsWith("/resource-memberships/me")) return json({ projectMemberships: {}, agentMemberships: {}, starredProjectIds: [], starredAgentIds: [], starredDocumentIds: [], projectStarredAt: {}, agentStarredAt: {}, documentStarredAt: {}, updatedAt: null });
if (path === `/api/companies/${companyId}/agents`) return json(agents);
if (path.endsWith("/dashboard")) return json(storybookDashboardSummary);
if (path === `/api/companies/${companyId}/activity`) return json(storybookActivityEvents);
if (path === `/api/companies/${companyId}/live-runs`) return json(storybookLiveRuns.map(run => ({ ...run, agentAppearance: agents.find(agent => agent.id === run.agentId)?.appearance })));
const agentMatch = path.match(/^\/api\/agents\/([^/]+)(?:\/(.*))?$/);
if (agentMatch) {
const agent = agents.find(item => item.id === agentMatch[1] || item.urlKey === agentMatch[1]) ?? agents[0];
if (!agentMatch[2]) return json(agent);
if (agentMatch[2] === "runtime-state") return json({ agentId: agent.id, companyId, adapterType: agent.adapterType, stateJson: {}, sessionId: null, sessionDisplayId: null, totalInputTokens: 42000, totalOutputTokens: 8200, totalCostCents: 340, lastRunStatus: "succeeded" });
if (agentMatch[2] === "skills") return json({ desiredSkills: [], actualSkills: [], errors: [] });
if (agentMatch[2] === "instructions-bundle") return previous(input, init);
return json([]);
}
const issueMatch = path.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/);
if (issueMatch) {
const item = storybookIssues.find(row => row.id === issueMatch[1] || row.identifier === issueMatch[1]) ?? issue;
if (!issueMatch[2]) return json({ ...item, assigneeAgentId: agents[0].id });
if (issueMatch[2] === "comments") return json([{ id: "persona-comment", issueId: item.id, companyId, authorAgentId: agents[0].id, authorUserId: null, body: "The implementation is ready for review. Ive checked the edge cases and added coverage for the new behavior.", createdAt: item.updatedAt, updatedAt: item.updatedAt }]);
if (issueMatch[2] === "active-run") return json(null);
if (issueMatch[2] === "queued-comments") return json({ queues: [], revision: "storybook", items: [] });
if (issueMatch[2] === "cost-summary") return json({ costCents: 340, inputTokens: 42000, outputTokens: 8200, runCount: 1 });
if (issueMatch[2].startsWith("documents/")) return new Response(null, { status: 404 });
return json([]);
}
if (path.endsWith("/budgets/overview")) return json({ companyId, policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0 });
if (path.includes("/heartbeat-runs/") && path.endsWith("/events")) return json([]);
// Reuse the preview's auth, company, adapter, issue-list and environment fixtures.
if (path === "/api/companies" || path.startsWith("/api/auth/") || path.includes("/adapters") || path.includes("/environments") || path.endsWith("/issues") || path.endsWith("/projects") || path.endsWith("/approvals") || path.endsWith("/sidebar-badges") || path.endsWith("/user-directory")) return previous(input, init);
if (path.includes("/settings")) return json({});
return json([]);
};
return () => { window.fetch = previous; };
}
function PersonaPage({ path, meet = false }: { path: string; meet?: boolean }) {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const [ready, setReady] = useState(false);
const [dialogOpen, setDialogOpen] = useState(meet);
const initialPath = useRef<string | null>(null);
useEffect(() => installPageFixtures(), []);
useEffect(() => {
if (initialPath.current === path) return;
initialPath.current = path;
queryClient.setQueryData(queryKeys.agents.list(companyId), agents);
for (const agent of agents) {
for (const ref of [agent.id, agent.urlKey]) queryClient.setQueryData([...queryKeys.agents.detail(ref!), companyId], agent);
}
setSelectedCompanyId(companyId);
navigate(path, { replace: true });
setReady(true);
}, [path, navigate, queryClient, setSelectedCompanyId]);
if (!ready || selectedCompanyId !== companyId || location.pathname === "/PAP/storybook") return null;
return <PluginLauncherProvider>
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="agents" element={<Agents />} />
<Route path="agents/all" element={<Agents />} />
<Route path="agents/new" element={<NewAgent />} />
<Route path="agents/:agentId/:tab?" element={<AgentDetail />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="dashboard" element={<Dashboard />} />
</Route>
</Routes>
{meet && <AgentBasicsDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onContinue={basics => { setDialogOpen(false); navigate(`/PAP/agents/new?name=${encodeURIComponent(basics.name)}&adapterType=${basics.adapterType}`); }} />}
</PluginLauncherProvider>;
}
const meta = { title: "Agents/Personas/Full pages", parameters: { layout: "fullscreen", a11y: { test: "off" } } } satisfies Meta;
export default meta;
type Story = StoryObj;
export const AllAgents: Story = { render: () => <PersonaPage path="/PAP/agents/all" /> };
export const AgentOverview: Story = { render: () => <PersonaPage path="/PAP/agents/codexcoder/overview" /> };
export const Task: Story = { render: () => <PersonaPage path={`/PAP/issues/${issue.identifier}`} /> };
export const CompanyDashboard: Story = { name: "Dashboard", render: () => <PersonaPage path="/PAP/dashboard" /> };
export const MeetYourNextAgent: Story = { render: () => <PersonaPage path="/PAP/agents/all" meet /> };
export const NewAgentConnection: Story = { render: () => <PersonaPage path="/PAP/agents/new?name=Carl&adapterType=codex_local" /> };