diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx
new file mode 100644
index 0000000000..8ab2d16624
--- /dev/null
+++ b/ui/src/components/AgentAvatar.tsx
@@ -0,0 +1,34 @@
+import { agentAvatarUrl } from "@/lib/agent-avatar-url";
+import { useState } from "react";
+import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
+import { cn } from "@/lib/utils";
+import { deriveInitials } from "./Identity";
+
+export type AvatarAgent = { id?: string; name?: string; appearance?: AgentAppearance | null; avatarUrl?: string | null };
+export const avatarSizeClasses: Record = {
+ 16: "size-4", 20: "size-5", 24: "size-6", 32: "size-8", 40: "size-10", 48: "size-12",
+ 64: "size-16", 96: "size-24", 128: "size-32", 256: "size-64", 512: "size-128",
+};
+export interface AgentAvatarProps {
+ agent?: AvatarAgent | null;
+ appearance?: AgentAppearance | null;
+ size?: AgentAvatarSize;
+ name?: string;
+ label?: string;
+ pose?: CharacterState;
+ muted?: boolean;
+ className?: string;
+}
+export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = "rest", muted = false, className }: AgentAvatarProps) {
+ const identity = resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id);
+ const src = agentAvatarUrl(identity, size, 1, pose, muted);
+ const [failedUrl, setFailedUrl] = useState(null);
+ return (
+
+ {failedUrl === src ? {deriveInitials(name ?? agent?.name ?? "Agent")} :
+ setFailedUrl(src)} />}
+
+ );
+}
diff --git a/ui/src/components/AgentCharacter.tsx b/ui/src/components/AgentCharacter.tsx
new file mode 100644
index 0000000000..d85bd2f650
--- /dev/null
+++ b/ui/src/components/AgentCharacter.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react";
+import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared";
+import { cn } from "@/lib/utils";
+import { characterSlot } from "@/lib/agent-character-slot";
+import { AgentAvatar, avatarSizeClasses, type AgentAvatarProps } from "./AgentAvatar";
+import type { createCharacter } from "@paperclipai/shared/cliplab/runtime";
+
+type Player = ReturnType;
+export interface AgentCharacterProps extends Omit {
+ state?: CharacterState;
+ motion?: "auto" | "still";
+ trackingRegion?: RefObject;
+ trackingScope?: "region" | "page";
+ followCursor?: boolean;
+ followRotation?: boolean;
+}
+export function AgentCharacter({ agent, appearance, size = 256, state = "idle", muted = false, motion = "auto", trackingRegion, trackingScope = "region", followCursor = true, followRotation = true, className, label, name }: AgentCharacterProps) {
+ const identity = useMemo(() => resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id), [appearance, agent?.appearance, agent?.id]);
+ const root = useRef(null), host = useRef(null), player = useRef(null);
+ const slotId = useRef(Symbol("agent-character"));
+ const owner = useSyncExternalStore(characterSlot.subscribe, characterSlot.getSnapshot, () => null);
+ const [visible, setVisible] = useState(false), [reduced, setReduced] = useState(true), [failed, setFailed] = useState(false), [ready, setReady] = useState(false);
+ const active = visible && !reduced && !failed && motion === "auto" && state !== "rest";
+ useEffect(() => {
+ if (typeof matchMedia !== "function" || typeof IntersectionObserver !== "function") return;
+ const media = matchMedia("(prefers-reduced-motion: reduce)");
+ const change = () => setReduced(media.matches); change(); media.addEventListener("change", change);
+ const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
+ if (root.current) observer.observe(root.current);
+ return () => { observer.disconnect(); media.removeEventListener("change", change); };
+ }, []);
+ useEffect(() => {
+ if (active && owner === null) characterSlot.acquire(slotId.current);
+ if (!active) characterSlot.release(slotId.current);
+ }, [active, owner]);
+ useEffect(() => () => characterSlot.release(slotId.current), []);
+ useEffect(() => {
+ if (!active || owner !== slotId.current) return;
+ let disposed = false;
+ setReady(false);
+ void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/definition")]).then(([runtime, library]) => {
+ if (disposed || !host.current) return;
+ player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), {
+ animation: library.animationId(state), trackingRegion: trackingRegion?.current ?? root.current ?? undefined,
+ followCursor, followRotation, trackingScope, displaySize: size, onError: () => setFailed(true),
+ });
+ setReady(true);
+ }).catch(() => { if (!disposed) setFailed(true); });
+ return () => { disposed = true; player.current?.destroy(); player.current = null; setReady(false); };
+ }, [active, owner, trackingRegion, trackingScope, size, followCursor, followRotation]);
+ useEffect(() => {
+ if (!player.current) return;
+ void import("@paperclipai/shared/cliplab/definition").then(library => {
+ player.current?.setDefinition(library.characterDefinition(identity, muted));
+ player.current?.setAnimation(library.animationId(state));
+ });
+ }, [identity, muted, state, ready]);
+ return
+
+
+ ;
+}
diff --git a/ui/src/components/AgentCharacterRuntime.test.ts b/ui/src/components/AgentCharacterRuntime.test.ts
new file mode 100644
index 0000000000..f240df5cb1
--- /dev/null
+++ b/ui/src/components/AgentCharacterRuntime.test.ts
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { createCharacter } from "@paperclipai/shared/cliplab/runtime";
+import { characterDefinition } from "@paperclipai/shared/cliplab/definition";
+import { appearanceForPalette } from "@paperclipai/shared";
+const renderer = vi.hoisted(() => ({ render: vi.fn(), resize: vi.fn(), dispose: vi.fn() }));
+vi.mock("@paperclipai/shared/cliplab/renderer", () => ({ CharacterRenderer: class { render = renderer.render; resize = renderer.resize; dispose = renderer.dispose; } }));
+let intersect: (entries: any[]) => void;
+let hidden = false;
+let frames: Map;
+let reduced: MediaQueryList;
+let coarse: MediaQueryList;
+let target: HTMLDivElement;
+let region: HTMLDivElement;
+const players: Array> = [];
+function media() { return Object.assign(new EventTarget(), { matches: false }) as unknown as MediaQueryList; }
+beforeEach(() => {
+ vi.clearAllMocks(); renderer.render.mockReset(); hidden = false; frames = new Map(); reduced = media(); coarse = media();
+ let id = 0;
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++id, callback); return id; });
+ vi.stubGlobal("cancelAnimationFrame", (key: number) => frames.delete(key));
+ vi.stubGlobal("matchMedia", (query: string) => query.includes("reduced-motion") ? reduced : coarse);
+ vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
+ vi.stubGlobal("IntersectionObserver", class { constructor(callback: typeof intersect) { intersect = callback; } observe() {} disconnect() {} });
+ vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
+ target = document.createElement("div"); region = document.createElement("div"); region.appendChild(target); document.body.appendChild(region);
+});
+afterEach(() => { players.splice(0).forEach(player => player.destroy()); region.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
+function create() {
+ const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingRegion: region });
+ players.push(player); return player;
+}
+it("stops scheduling offscreen, when hidden, for reduced motion, and after disposal", () => {
+ const player = create(); expect(frames.size).toBe(0);
+ intersect([{ isIntersecting: true }]); expect(frames.size).toBe(1);
+ hidden = true; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(0);
+ hidden = false; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(1);
+ Object.assign(reduced, { matches: true }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(0);
+ Object.assign(reduced, { matches: false }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(1);
+ intersect([{ isIntersecting: false }]); expect(frames.size).toBe(0);
+ intersect([{ isIntersecting: true }]); player.destroy(); player.destroy();
+ expect(frames.size).toBe(0); expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0);
+});
+it("tracks only its region, ignores touch, and removes listeners for coarse pointers", () => {
+ const add = vi.spyOn(region, "addEventListener"), remove = vi.spyOn(region, "removeEventListener");
+ const documentAdd = vi.spyOn(document, "addEventListener");
+ create(); intersect([{ isIntersecting: true }]);
+ expect(add.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
+ expect(documentAdd.mock.calls.some(([event]) => event === "pointermove")).toBe(false);
+ const before = renderer.render.mock.calls.at(-1)?.[3];
+ region.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "touch", clientX: 100, clientY: 100 }));
+ const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 16);
+ expect(renderer.render.mock.calls.at(-1)?.[3]).toEqual(before);
+ Object.assign(coarse, { matches: true }); coarse.dispatchEvent(new Event("change"));
+ expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
+});
+it("disposes the canvas and subscriptions when the first render fails", () => {
+ renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
+ expect(() => create()).toThrow("lost context");
+ expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0); expect(frames.size).toBe(0);
+});
+
+it("falls back when a later expression render fails", () => {
+ const onError = vi.fn();
+ const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { onError });
+ players.push(player); intersect([{ isIntersecting: true }]);
+ renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
+ expect(() => player.setAnimation("success")).not.toThrow();
+ expect(onError).toHaveBeenCalledOnce(); expect(frames.size).toBe(0);
+});
+
+it("can follow the whole page while measuring gaze from the character and cleaning up", () => {
+ const page = document.documentElement;
+ const remove = vi.spyOn(page, "removeEventListener");
+ vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ left: 100, top: 100, width: 100, height: 100 } as DOMRect);
+ const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingScope: "page", displaySize: 256 });
+ players.push(player); intersect([{ isIntersecting: true }]);
+ // Outside the character and its parent region, but still on the page.
+ page.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "mouse", clientX: 900, clientY: 150 }));
+ const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 50);
+ const gaze = renderer.render.mock.calls.at(-1)?.[3];
+ expect(gaze.x).toBeGreaterThan(0); expect(gaze.y).toBe(0);
+ player.destroy();
+ expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
+});
diff --git a/ui/src/components/AgentIdentity.tsx b/ui/src/components/AgentIdentity.tsx
new file mode 100644
index 0000000000..86f7e38d11
--- /dev/null
+++ b/ui/src/components/AgentIdentity.tsx
@@ -0,0 +1,9 @@
+import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
+import { cn } from "@/lib/utils";
+export function AgentIdentity({ agent, size = "default", className }: { agent: AvatarAgent; size?: "xs" | "sm" | "default" | "lg"; className?: string }) {
+ const pixels = { xs: 20, sm: 24, default: 32, lg: 40 } as const;
+ return
+
+ {agent.name ?? "Agent"}
+ ;
+}
diff --git a/ui/src/components/AgentMultiSelect.tsx b/ui/src/components/AgentMultiSelect.tsx
index ad0579895c..5c38fec2c3 100644
--- a/ui/src/components/AgentMultiSelect.tsx
+++ b/ui/src/components/AgentMultiSelect.tsx
@@ -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);
}}
>
-
+
{agent.name}
{agent.title ? {agent.title} : null}
@@ -261,7 +261,7 @@ export function AgentMultiSelect({
setSelection(next);
}}
/>
-
+
{agent.name}
@@ -306,7 +306,7 @@ export function AgentMultiSelect({
{selectedAgents.slice(0, 3).map((agent) => (
))}
diff --git a/ui/src/components/AgentPersona.test.tsx b/ui/src/components/AgentPersona.test.tsx
new file mode 100644
index 0000000000..340166f9a7
--- /dev/null
+++ b/ui/src/components/AgentPersona.test.tsx
@@ -0,0 +1,89 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { appearanceForPalette } from "@paperclipai/shared";
+import { AgentAvatar } from "./AgentAvatar";
+import { AgentCharacter } from "./AgentCharacter";
+import { useAgentAppearanceDraft } from "../hooks/useAgentAppearanceDraft";
+
+const renderer = vi.hoisted(() => ({ destroy: vi.fn(), setDefinition: vi.fn(), setAnimation: vi.fn() }));
+const createCharacter = vi.hoisted(() => vi.fn(() => renderer));
+vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter }));
+vi.mock("@paperclipai/shared/cliplab/definition", () => ({ characterDefinition: vi.fn(value => value), animationId: vi.fn(value => value) }));
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+let root: Root;
+let host: HTMLDivElement;
+let observers: Array<(entries: any[]) => void>;
+let reduced = false;
+const appearance = appearanceForPalette("bubblegum-sky");
+beforeEach(() => {
+ vi.clearAllMocks(); observers = []; reduced = false;
+ sessionStorage.clear();
+ host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
+ vi.stubGlobal("IntersectionObserver", class {
+ constructor(callback: (entries: any[]) => void) { observers.push(callback); }
+ observe() {} disconnect() {}
+ });
+ vi.stubGlobal("matchMedia", () => ({ matches: reduced, addEventListener() {}, removeEventListener() {} }));
+});
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
+async function show() { await act(async () => { for (const observer of observers) observer([{ isIntersecting: true }]); }); }
+describe("agent persona presentation", () => {
+ it("renders 500 static images without initializing a live renderer", async () => {
+ await act(async () => root.render(<>{Array.from({ length: 500 }, (_, i) =>
)}>));
+ expect(host.querySelectorAll("img")).toHaveLength(500);
+ expect(host.querySelector("canvas")).toBeNull();
+ expect(createCharacter).not.toHaveBeenCalled();
+ expect(host.querySelector("img")?.getAttribute("srcset")).toContain("size=24&scale=2");
+ expect(host.querySelector("img")?.getAttribute("width")).toBe("24");
+ });
+ it("keeps reserved image dimensions and falls back to initials after an image error", async () => {
+ await act(async () => root.render(
));
+ expect(host.querySelector("img")?.height).toBe(48);
+ await act(async () => { host.querySelector("img")!.dispatchEvent(new Event("error")); });
+ expect(host.textContent).toBe("CS");
+ expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("Chief of Staff");
+ });
+ it("allows only one live character, releases it offscreen and disposes on unmount", async () => {
+ await act(async () => root.render(<>
>));
+ expect(createCharacter).not.toHaveBeenCalled();
+ await show();
+ expect(createCharacter).toHaveBeenCalledTimes(1);
+ await act(async () => { observers[0]([{ isIntersecting: false }]); });
+ expect(renderer.destroy).toHaveBeenCalledTimes(1);
+ expect(createCharacter).toHaveBeenCalledTimes(2);
+ await act(async () => root.render(null));
+ expect(renderer.destroy).toHaveBeenCalledTimes(2);
+ });
+ it("uses only static images for reduced motion and an explicit still policy", async () => {
+ reduced = true;
+ await act(async () => root.render(<>
>));
+ await show();
+ expect(createCharacter).not.toHaveBeenCalled();
+ expect(host.querySelectorAll("img")).toHaveLength(2);
+ });
+ it("selects the correct saved draft when the company changes without remounting", async () => {
+ let draft!: ReturnType
;
+ function Draft({ company }: { company: string }) { draft = useAgentAppearanceDraft(`${company}:new-agent`); return null; }
+ sessionStorage.setItem("paperclip.agent-appearance.two:new-agent", JSON.stringify(appearance));
+ await act(async () => root.render( ));
+ const first = draft.appearance;
+ await act(async () => root.render( ));
+ expect(draft.appearance).toEqual(appearance);
+ await act(async () => root.render( ));
+ expect(draft.appearance).toEqual(first);
+ });
+ it("retains the draft assignment across remounts and clears it only after creation", async () => {
+ let draft!: ReturnType;
+ function Draft() { draft = useAgentAppearanceDraft("company:new-agent"); return null; }
+ await act(async () => root.render( ));
+ const first = draft.appearance;
+ await act(async () => root.render(null));
+ await act(async () => root.render( ));
+ expect(draft.appearance).toEqual(first);
+ expect(JSON.parse(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")!)).toEqual(first);
+ draft.clear();
+ expect(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")).toBeNull();
+ });
+});
diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx
index c0fdffd1d7..a2cbd4dd08 100644
--- a/ui/src/components/AgentProperties.tsx
+++ b/ui/src/components/AgentProperties.tsx
@@ -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) {
{reportsToAgent ? (
-
+
) : (
{agent.reportsTo.slice(0, 8)}
diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx
index abfe67cae0..55c7a957cb 100644
--- a/ui/src/components/ApprovalCard.tsx
+++ b/ui/src/components/ApprovalCard.tsx
@@ -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 && (
)}
diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx
index f8f18a6cbe..dee2ec3c7a 100644
--- a/ui/src/components/BlockedInboxView.tsx
+++ b/ui/src/components/BlockedInboxView.tsx
@@ -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;
+ agents?: import("./AgentAvatar").AvatarAgent[];
userLabelById?: ReadonlyMap;
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;
+ agents?: import("./AgentAvatar").AvatarAgent[];
userLabelById?: ReadonlyMap;
liveIssueIds: ReadonlySet;
subtreeLiveCounts: ReadonlyMap;
@@ -301,6 +305,7 @@ function BlockedInboxRow({
row,
issueLinkState,
agentNameById,
+ agents,
userLabelById,
liveIssueIds,
subtreeLiveCounts,
@@ -330,11 +335,7 @@ function BlockedInboxRow({
{ownerName ? (
-
+ {isAgent ? agent.id === row.attention.owner.agentId) ?? { id: row.attention.owner.agentId ?? undefined, name: ownerName }} size="xs" className="max-w-full" /> : }
) : (
diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx
index 320547f2ff..25f3d2ace6 100644
--- a/ui/src/components/CommandPalette.tsx
+++ b/ui/src/components/CommandPalette.tsx
@@ -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() {
{issue.title}
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
- return name ? : null;
+ return name ? agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
})()}
))}
diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx
index 6f26b23ae9..328bf906ca 100644
--- a/ui/src/components/CommentThread.tsx
+++ b/ui/src/components/CommentThread.tsx
@@ -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({
{comment.authorAgentId ? (
-
@@ -478,9 +479,9 @@ function TimelineEventCard({
return (
-
- {initialsForName(actorName)}
-
+ {event.actorType === "agent" ?
: (
+
{initialsForName(actorName)}
+ )}
@@ -624,9 +625,7 @@ const TimelineList = memo(function TimelineList({
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
return (
-
- {initialsForName(actorName)}
-
+
@@ -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 ? (
-
+
) : null}
{option.label}
>
@@ -1073,7 +1073,7 @@ export function CommentThread({
return (
<>
{agent ? (
-
+
) : null}
{option.label}
>
diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx
index 52de5c6ec0..9ba72ec627 100644
--- a/ui/src/components/DocumentAnnotationPanel.tsx
+++ b/ui/src/components/DocumentAnnotationPanel.tsx
@@ -1,3 +1,4 @@
+import { AgentAvatar } from "./AgentAvatar";
import { useCallback, 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 { useCopyToast } from "@/lib/use-copy-action";
-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 `
` to a display name. */
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
/** Resolve `` to a display name. */
userProfileMap?: ReadonlyMap;
}
@@ -327,7 +327,7 @@ export function ThreadCard(props: {
onCopyLink: () => void;
pendingReply: boolean;
pendingStatus: boolean;
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
}) {
const { thread } = props;
@@ -462,7 +462,7 @@ function CommentRow({
}: {
comment: DocumentAnnotationComment;
focused: boolean;
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
}) {
const author = resolveAuthor(comment, { agentMap, userProfileMap });
@@ -477,18 +477,11 @@ function CommentRow({
>
+ {author.role === "agent" ? : (
- {author.role === "agent" ? (
-
-
-
- ) : (
- <>
- {author.imageUrl ? : null}
- {deriveInitials(author.name)}
- >
- )}
-
+ {author.imageUrl ? : null}
+ {deriveInitials(author.name)}
+ )}
{author.name}
{author.role === "agent" ? (
· agent
@@ -509,16 +502,17 @@ function isSubmitShortcut(event: React.KeyboardEvent): bool
function resolveAuthor(
comment: DocumentAnnotationComment,
maps: {
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
},
-): { 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) {
diff --git a/ui/src/components/DocumentFrameHeader.tsx b/ui/src/components/DocumentFrameHeader.tsx
index bb88c48e12..5ceb670964 100644
--- a/ui/src/components/DocumentFrameHeader.tsx
+++ b/ui/src/components/DocumentFrameHeader.tsx
@@ -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 ;
return (
-
- {actor.kind === "agent" ? (
-
-
-
- ) : (
- <>
- {actor.imageUrl ? : null}
- {deriveInitials(actor.name)}
- >
- )}
+
+ {actor.imageUrl ? : null}
+ {deriveInitials(actor.name)}
);
}
diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx
index 3acfdbd74d..b13d60748f 100644
--- a/ui/src/components/ExecutionParticipantPicker.tsx
+++ b/ui/src/components/ExecutionParticipantPicker.tsx
@@ -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)}
>
-
+
{agent.name}
);
diff --git a/ui/src/components/FeedCard.tsx b/ui/src/components/FeedCard.tsx
index e88c0bb9c7..91e703848c 100644
--- a/ui/src/components/FeedCard.tsx
+++ b/ui/src/components/FeedCard.tsx
@@ -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 (
-
+
);
}
if (content.actorType === "user") {
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index c4cf424354..f5fb590527 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -1,3 +1,4 @@
+import { AgentAvatar } from "@/components/AgentAvatar";
import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover";
import { useEmailComment } from "./EmailMessageCard";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
@@ -148,7 +149,6 @@ import {
type InlineEntityOption,
} from "./InlineEntitySelector";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
-import { AgentIcon } from "./AgentIconPicker";
import {
AssigneeChip,
ComposerHandoffPreviewRow,
@@ -1289,8 +1289,8 @@ function IssueChatChainOfThought({
- {agentIcon ? (
-
+ {agentId ? (
+
) : isActive ? (
) : (
@@ -2426,17 +2426,7 @@ function IssueChatAssistantMessage({
!isRunning &&
(hasCommentText || deleted);
- const agentAvatar = (
-
- {agentIcon ? (
-
-
-
- ) : (
- {initialsForName(authorName)}
- )}
-
- );
+ const agentAvatar = ;
const messageActionBar = (
@@ -2562,8 +2552,8 @@ function IssueChatAssistantMessage({
{/* Icon + name together in a header ABOVE the bubble (PAP-95 rev 7). */}
- {agentIcon ? (
-
+ {agentId ? (
+
) : (
@@ -2715,11 +2705,8 @@ function IssueChatAssistantMessage({
- {agentIcon ? (
-
+ {agentId ? (
+
) : (
)}
@@ -3089,15 +3076,11 @@ function ExpiredRequestConfirmationActivity({
) : (
-
- {actorIcon ? (
-
-
-
- ) : (
- {initialsForName(actorName)}
- )}
-
+ {actorAgentId ? (
+
+ ) : (
+
{initialsForName(actorName)}
+ )}
{rowContent}
)}
@@ -3814,15 +3797,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 ? (
-
- ) : (
-
- );
+ const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
+ const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
+ const rowIcon = isAgent
+ ?
+ :
;
const handoffResolvers: HandoffChipResolvers = {
agentMap,
currentUserId,
@@ -3917,18 +3896,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 ? (
-
- ) : (
-
- );
+ if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
+ const rowIcon =
;
return (
@@ -5662,10 +5631,7 @@ const IssueChatComposer = forwardRef<
return (
<>
{agent ? (
-
+
) : null}
{option.label}
>
@@ -5681,10 +5647,7 @@ const IssueChatComposer = forwardRef<
return (
<>
{agent ? (
-
+
) : null}
{option.label}
>
diff --git a/ui/src/components/IssueColumns.test.tsx b/ui/src/components/IssueColumns.test.tsx
index d63f1ad8a9..b969481cbd 100644
--- a/ui/src/components/IssueColumns.test.tsx
+++ b/ui/src/components/IssueColumns.test.tsx
@@ -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(
{
);
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", () => {
diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx
index 11d4b8fcf8..324a9d9597 100644
--- a/ui/src/components/IssueColumns.tsx
+++ b/ui/src/components/IssueColumns.tsx
@@ -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 (
-
@@ -359,10 +364,9 @@ export function InboxIssueTrailingColumns({
-
diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx
index 7f573ab4e3..5636fe20c6 100644
--- a/ui/src/components/IssueDocumentAnnotations.test.tsx
+++ b/ui/src/components/IssueDocumentAnnotations.test.tsx
@@ -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);
});
diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx
index a2a008f014..e29fd54ff9 100644
--- a/ui/src/components/IssueDocumentsSection.test.tsx
+++ b/ui/src/components/IssueDocumentsSection.test.tsx
@@ -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();
diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx
index eeadaf49b3..e4e0813a2c 100644
--- a/ui/src/components/IssueDocumentsSection.tsx
+++ b/ui/src/components/IssueDocumentsSection.tsx
@@ -149,7 +149,7 @@ function downloadDocumentFile(key: string, body: string) {
function getRevisionActor(
revision: DocumentRevision,
maps: {
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
},
): 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;
extraActions?: ReactNode;
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
/**
* Seed which document annotation panels are open on first render. Mostly useful
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
index 8f72726293..6b3492230e 100644
--- a/ui/src/components/IssueProperties.test.tsx
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -888,7 +888,7 @@ describe("IssueProperties", () => {
expect(container.textContent).not.toContain("Responsible");
expect(container.textContent).not.toContain("Kicked off by");
expect(container.textContent).not.toContain("Created by");
- expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
+ expect(container.querySelector('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/");
});
act(() => root.unmount());
@@ -939,7 +939,7 @@ describe("IssueProperties", () => {
expect(container.textContent).toContain("Unassigned");
expect(container.textContent).not.toContain("Responsible");
expect(container.textContent).not.toContain("Kicked off by");
- expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
+ expect(container.querySelector('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/");
});
act(() => root.unmount());
diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx
index 5f1569d316..2a947c66db 100644
--- a/ui/src/components/IssuesList.tsx
+++ b/ui/src/components/IssuesList.tsx
@@ -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) ? (
-
+ agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
-
+
))}
diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx
index f251729e34..9b1c19f40e 100644
--- a/ui/src/components/KanbanBoard.tsx
+++ b/ui/src/components/KanbanBoard.tsx
@@ -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 ? (
-
+ agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="xs" />
) : (
{issue.assigneeAgentId.slice(0, 8)}
diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx
index 2e4e606701..964ae78482 100644
--- a/ui/src/components/LegacyIssuesList.tsx
+++ b/ui/src/components/LegacyIssuesList.tsx
@@ -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) ? (
-
+ agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
) : issue.assigneeUserId ? (
-
+
))}
diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx
index 51bfcef469..62852ff3b6 100644
--- a/ui/src/components/LiveRunWidget.tsx
+++ b/ui/src/components/LiveRunWidget.tsx
@@ -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) {
-
+
({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) =>
{children} }));
+const routines = [{ key: "routine", title: "Daily summary", status: "active", assigneeAgentId: "agent-one" }];
+function avatarSrc(markup: string) {
+ const document = new DOMParser().parseFromString(markup, "text/html");
+ return document.querySelector('[data-slot="agent-avatar"] img')?.getAttribute("src");
+}
+describe("routine agent identities", () => {
+ it("keeps a saved palette for agents without legacy icons", () => {
+ const appearance = appearanceForPalette("bubblegum-sky");
+ const markup = renderToStaticMarkup(
);
+ expect(avatarSrc(markup)).toBe(agentAvatarUrl(appearance, 16));
+ });
+ it("uses the assignee ID for a stable fallback before the agent summary loads", () => {
+ const markup = renderToStaticMarkup(
);
+ expect(avatarSrc(markup)).toBe(agentAvatarUrl(resolveAgentAppearance(null, "agent-one"), 16));
+ });
+});
diff --git a/ui/src/components/ManagedRoutinesList.tsx b/ui/src/components/ManagedRoutinesList.tsx
index 0ac65812f8..5b85b6910d 100644
--- a/ui/src/components/ManagedRoutinesList.tsx
+++ b/ui/src/components/ManagedRoutinesList.tsx
@@ -6,11 +6,7 @@ import {
type RoutineListRowItem,
} from "@/components/RoutineList";
-export type ManagedRoutinesListAgent = {
- id: string;
- name: string;
- icon?: string | null;
-};
+export type ManagedRoutinesListAgent = RoutineListAgentSummary & { id: string };
export type ManagedRoutinesListProject = {
id: string;
@@ -94,7 +90,7 @@ export function ManagedRoutinesList({
onReset,
}: ManagedRoutinesListProps) {
const agentById = new Map
(
- agents.map((agent) => [agent.id, { name: agent.name, icon: agent.icon }]),
+ agents.map((agent) => [agent.id, agent]),
);
const projectById = new Map(
projects.map((project) => [project.id, { name: project.name, color: project.color }]),
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index d16c411ae0..1b1190939e 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -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
) : option.kind === "user" ? (
) : (
-
+
)}
{option.kind === "issue" && option.issueIdentifier ? (
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index a4e76cce05..bdd038a73a 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -1524,7 +1524,7 @@ export function NewIssueDialog() {
: null;
return (
<>
- {assignee ? : null}
+ {assignee ? : null}
{option.label}
{assignee && getTrustPreset(assignee.permissions) === "low_trust_review" ? (
@@ -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 ? : null;
+ return reviewer ? : null;
})()}
{option.label}
>
@@ -1680,7 +1680,7 @@ export function NewIssueDialog() {
: null;
return (
<>
- {reviewer ? : null}
+ {reviewer ? : null}
{option.label}
>
);
@@ -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 ? : null;
+ return approver ? : null;
})()}
{option.label}
>
@@ -1725,7 +1725,7 @@ export function NewIssueDialog() {
: null;
return (
<>
- {approver ? : null}
+ {approver ? : null}
{option.label}
>
);
@@ -1747,7 +1747,7 @@ export function NewIssueDialog() {
>
{selectedWatchdogAgent ? (
<>
-
+
{selectedWatchdogAgent.name}
{watchdogInstructions.trim() ? (
· {watchdogInstructions.trim()}
@@ -1773,7 +1773,7 @@ export function NewIssueDialog() {
option ? (
<>
{selectedWatchdogAgent ? (
-
+
) : null}
{option.label}
>
@@ -1785,7 +1785,7 @@ export function NewIssueDialog() {
const agent = (agents ?? []).find((a) => a.id === option.id);
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index ee2005bda9..43915d134c 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -6,6 +6,8 @@ import { aiProviderForAdapter } from "./ai-connections/AiConnectionField";
import type { AiConnectionBinding } from "@paperclipai/shared";
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";
@@ -556,6 +558,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
@@ -776,6 +779,7 @@ function OnboardingWizardInner({
* hand rather than the one before it.
*/
function clearCompanyScopedState() {
+ setAgentAppearance(randomAgentAppearance());
setCreatedCompanyPrefix(null);
setCompanyName("");
setCreatedCompanyGoalId(null);
@@ -879,7 +883,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,
@@ -888,7 +892,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,
@@ -1600,6 +1604,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("");
@@ -2157,6 +2162,7 @@ function OnboardingWizardInner({
if (existing) {
if (!isCurrent()) return;
setCreatedAgentId(existing.id);
+ setAgentAppearance(resolveAgentAppearance(existing.appearance, existing.id));
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
@@ -2171,6 +2177,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,
@@ -2454,7 +2461,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 3–5, 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. */}
@@ -2487,14 +2494,7 @@ function OnboardingWizardInner({
to this box and travel out past its top-right
corner. */}
-
- {/* 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 &&
}
+
diff --git a/ui/src/components/PipelineItemBodyDocument.tsx b/ui/src/components/PipelineItemBodyDocument.tsx
index 7b784e6417..8347315078 100644
--- a/ui/src/components/PipelineItemBodyDocument.tsx
+++ b/ui/src/components/PipelineItemBodyDocument.tsx
@@ -38,7 +38,7 @@ type CaseBodyDocument = PipelineCaseDocumentPayload["document"] & {
function getPipelineRevisionActor(
revision: { createdByAgentId?: string | null; createdByUserId?: string | null },
maps: {
- agentMap?: ReadonlyMap & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
},
): 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 & Partial>>;
+ agentMap?: ReadonlyMap & Partial>>;
userProfileMap?: ReadonlyMap;
mentions?: MentionOption[];
imageUploadHandler?: (file: File) => Promise;
diff --git a/ui/src/components/ReportsToPicker.tsx b/ui/src/components/ReportsToPicker.tsx
index d3a4bf0d0b..706375c6bd 100644
--- a/ui/src/components/ReportsToPicker.tsx
+++ b/ui/src/components/ReportsToPicker.tsx
@@ -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 ? (
<>
-
+
{terminatedManager && (
-
+
Current: {current.name} (terminated)
@@ -115,7 +115,7 @@ export function ReportsToPicker({
setOpen(false);
}}
>
-
+
{a.name}
{roleLabels[a.role] ?? a.role}
diff --git a/ui/src/components/RoutineList.tsx b/ui/src/components/RoutineList.tsx
index 29604651c1..332422307f 100644
--- a/ui/src/components/RoutineList.tsx
+++ b/ui/src/components/RoutineList.tsx
@@ -1,7 +1,8 @@
+import type { AgentAppearance } from "@paperclipai/shared";
+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,
@@ -18,6 +19,8 @@ export type RoutineListProjectSummary = {
};
export type RoutineListAgentSummary = {
+ id?: string;
+ appearance?: AgentAppearance | null;
name: string;
icon?: string | null;
};
@@ -148,7 +151,7 @@ export function RoutineListRow
({
{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}
- {agent?.icon ? : null}
+ {routine.assigneeAgentId ? : null}
{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}
diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx
index ed5c2b1fe9..65052867c6 100644
--- a/ui/src/components/RoutineRunVariablesDialog.tsx
+++ b/ui/src/components/RoutineRunVariablesDialog.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -389,7 +389,7 @@ export function RoutineRunVariablesDialog({
const assignee = agents.find((agent) => agent.id === option.id);
return (
<>
- {assignee ? : null}
+ {assignee ? : null}
{option.label}
>
);
diff --git a/ui/src/components/SidebarAgents.production.tsx b/ui/src/components/SidebarAgents.production.tsx
index 028c8de32a..c2d0bcfe4d 100644
--- a/ui/src/components/SidebarAgents.production.tsx
+++ b/ui/src/components/SidebarAgents.production.tsx
@@ -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({
}
+ iconNode={ }
active={isActive}
liveCount={runCount}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx
index 806e1fd294..0ae054f9ea 100644
--- a/ui/src/components/SidebarAgents.tsx
+++ b/ui/src/components/SidebarAgents.tsx
@@ -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({
}
+ iconNode={ }
active={isActive}
liveCount={runCount}
labelClassName={showBuiltInLifecycle ? "min-w-(--sz-4_5rem) flex-initial" : undefined}
diff --git a/ui/src/components/StageSecretsPanel.tsx b/ui/src/components/StageSecretsPanel.tsx
index 5ba0846395..76cc8e777d 100644
--- a/ui/src/components/StageSecretsPanel.tsx
+++ b/ui/src/components/StageSecretsPanel.tsx
@@ -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({
{agentName ? (
-
+
) : (
)}
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 721d7f2e32..6687365efa 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -1656,6 +1656,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,
@@ -1873,7 +1874,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,
@@ -1963,6 +1965,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,
@@ -2888,6 +2891,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}
agentName={visibleTailAgentName}
agentIcon={visibleTailAgentIcon}
+ agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
items={tailItems}
status={
optimisticRunnerStartup
diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
index bd66a32000..1c9049dc76 100644
--- a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
+++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx
@@ -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 (
Agent
-
+
{agentName(assignee.agentId, resolvers)}
);
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 1ffb39d231..00bbb7f54a 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -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({
{(() => {
const agent = (agents ?? []).find((candidate) => candidate.id === issue.watchdog?.watchdogAgentId);
- return agent ? : null;
+ return agent ? : null;
})()}
{agentName(issue.watchdog.watchdogAgentId)}
{issue.watchdog.instructions?.trim() ? (
@@ -1185,7 +1186,7 @@ export function IssueProperties({
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -1194,7 +1195,7 @@ export function IssueProperties({
const agent = (agents ?? []).find((candidate) => candidate.id === option.id);
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -1691,7 +1692,7 @@ export function IssueProperties({
);
const assigneeTrigger = assignee ? (
-
+
) : assigneeUserLabel ? (
<>
@@ -1771,7 +1772,7 @@ export function IssueProperties({
}}
>
{option.kind === "agent" ? (
-
+
) : option.kind === "user" ? (
) : null}
@@ -1930,7 +1931,7 @@ export function IssueProperties({
)}
onClick={() => toggleExecutionParticipant(stageType, encoded)}
>
-
+
{agent.name}
);
@@ -2877,11 +2878,7 @@ export function IssueProperties({
to={`/agents/${originatingActor.id}`}
className="hover:underline"
>
-
+ agent.id === originatingActor.id) ?? { id: originatingActor.id, name: agentName(originatingActor.id) ?? "Agent" }} size="sm" />
) : (
@@ -2945,7 +2942,7 @@ export function IssueProperties({
title={`Archived by ${archivedByName} · ${formatDateTime(issue.archivedAt)}`}
>
{archivedByAgent
- ?
+ ?
: null}
{archivedByName}
diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx
index 1b7ee5b27e..de8a3f7a84 100644
--- a/ui/src/components/new-agent/AgentBasicsDialog.tsx
+++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx
@@ -1,3 +1,6 @@
+import { useCompany } from "@/context/CompanyContext";
+import { useAgentAppearanceDraft } from "@/hooks/useAgentAppearanceDraft";
+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 +19,6 @@ import {
DialogDescription,
DialogTitle,
} from "../ui/dialog";
-import { PillGuy } from "../onboarding/PillGuy";
export type AgentBasics = {
name: string;
@@ -79,6 +81,12 @@ export function AdapterMark({
>
);
}
+function AgentBasicsCharacter() {
+ const { selectedCompanyId } = useCompany();
+ const { appearance } = useAgentAppearanceDraft(`${selectedCompanyId}:new-agent`);
+ return ;
+}
+
export function AgentBasicsDialog({
open,
onClose,
@@ -166,7 +174,7 @@ export function AgentBasicsDialog({
>
-
+ {open &&
}
{step === "name"
diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx
index 80a65dfdae..5ebb4e318b 100644
--- a/ui/src/components/new-agent/NewAgentSetup.tsx
+++ b/ui/src/components/new-agent/NewAgentSetup.tsx
@@ -1,3 +1,5 @@
+import { AgentCharacter } from "../AgentCharacter";
+import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
import type { AiConnectionBinding } from "@paperclipai/shared";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
@@ -51,7 +53,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,
@@ -107,6 +108,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"
@@ -497,6 +499,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,
@@ -517,6 +520,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 })}`,
@@ -624,10 +628,9 @@ function Setup({
-
+
{name}
diff --git a/ui/src/components/onboarding/ConnectModelPreview.tsx b/ui/src/components/onboarding/ConnectModelPreview.tsx
index fdd6f2acb2..56f6aaf0de 100644
--- a/ui/src/components/onboarding/ConnectModelPreview.tsx
+++ b/ui/src/components/onboarding/ConnectModelPreview.tsx
@@ -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. */}
diff --git a/ui/src/components/routine-sections/editable-sections.production.tsx b/ui/src/components/routine-sections/editable-sections.production.tsx
index 49e79c2522..a7adf99d0f 100644
--- a/ui/src/components/routine-sections/editable-sections.production.tsx
+++ b/ui/src/components/routine-sections/editable-sections.production.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -193,7 +193,7 @@ export function OverviewSection({
return (
<>
{assignee ? (
-
+
) : null}
{option.label}
>
diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx
index 4321a3ec44..3be34a09c1 100644
--- a/ui/src/components/routine-sections/editable-sections.tsx
+++ b/ui/src/components/routine-sections/editable-sections.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -191,7 +191,7 @@ export function OverviewSection({
return (
<>
{assignee ? (
-
+
) : null}
{option.label}
>
diff --git a/ui/src/components/search/SearchResultRow.tsx b/ui/src/components/search/SearchResultRow.tsx
index d159fdfd19..ea118b63e9 100644
--- a/ui/src/components/search/SearchResultRow.tsx
+++ b/ui/src/components/search/SearchResultRow.tsx
@@ -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
>;
+ agentsById?: ReadonlyMap>;
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"
>
-
-
-
+
{result.title}
@@ -214,7 +214,7 @@ function SearchResultRowImpl({
{assigneeName || updated ? (
- {assigneeName ?
: null}
+ {assigneeName ?
: null}
{updated ?
{updated} : null}
) : null}
diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx
index bc51a51133..2562c31d09 100644
--- a/ui/src/components/task-chat/TaskChatBubble.tsx
+++ b/ui/src/components/task-chat/TaskChatBubble.tsx
@@ -1,3 +1,4 @@
+import { AgentAvatar, type AvatarAgent } from "../AgentAvatar";
import { useCallback, useContext, useState, type ReactNode } from "react";
import { useEmailComment } from "@/components/EmailMessageCard";
import type { IssueAttachment } from "@paperclipai/shared";
@@ -9,8 +10,6 @@ import {
ImageGalleryModal,
type GalleryMediaItem,
} from "@/components/ImageGalleryModal";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { AgentIcon } from "@/components/AgentIconPicker";
import {
Attachment,
AttachmentContent,
@@ -74,28 +73,18 @@ function initialsForName(name: string) {
export function TaskChatAgentIdentity({
agentName,
agentIcon,
+ agent,
}: {
agentName: string;
agentIcon?: string | null;
+ agent?: AvatarAgent;
}) {
return (
-
- {agentIcon ? (
-
-
-
- ) : (
- {initialsForName(agentName)}
- )}
-
+
{agentName}
);
@@ -232,6 +221,7 @@ function TaskChatBubbleContent({
) : null}
{bodyText.length > 0 ? (
diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx
index e59dcfd42f..cbe5740466 100644
--- a/ui/src/components/task-chat/TaskChatComposer.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.tsx
@@ -1,3 +1,4 @@
+import { AgentAvatar } from "../AgentAvatar";
import {
useEffect,
useRef,
@@ -65,7 +66,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";
@@ -123,7 +123,7 @@ interface TaskChatComposerProps {
enableReassign?: boolean;
conversationMode?: boolean;
reassignOptions?: InlineEntityOption[];
- agentMap?: ReadonlyMap
;
+ agentMap?: ReadonlyMap;
userProfileMap?: ReadonlyMap<
string,
{ label: string; image: string | null }
@@ -246,7 +246,7 @@ function AssigneeIdentityAvatar({
}: {
assigneeValue: string;
label: string;
- agentMap: ReadonlyMap | undefined;
+ agentMap: ReadonlyMap | undefined;
userProfileMap:
| ReadonlyMap
| null
@@ -257,8 +257,7 @@ function AssigneeIdentityAvatar({
const agentId = assigneeValue.slice("agent:".length);
const icon = agentMap?.get(agentId)?.icon ?? "bot";
return (
-
-
-
-
-
+
+
);
}
diff --git a/ui/src/components/task-chat/TaskChatDescriptionBubble.test.tsx b/ui/src/components/task-chat/TaskChatDescriptionBubble.test.tsx
index b2f4c23dc5..111459fb50 100644
--- a/ui/src/components/task-chat/TaskChatDescriptionBubble.test.tsx
+++ b/ui/src/components/task-chat/TaskChatDescriptionBubble.test.tsx
@@ -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();
diff --git a/ui/src/components/task-chat/TaskChatDescriptionBubble.tsx b/ui/src/components/task-chat/TaskChatDescriptionBubble.tsx
index ed4b6b133b..0cb2630b98 100644
--- a/ui/src/components/task-chat/TaskChatDescriptionBubble.tsx
+++ b/ui/src/components/task-chat/TaskChatDescriptionBubble.tsx
@@ -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;
@@ -115,15 +116,7 @@ export function TaskChatDescriptionBubble({ brief }: TaskChatDescriptionBubblePr
>
{!isHuman && brief.authorName ? (
-
- {brief.agentIcon ? (
-
-
-
- ) : (
- {initialsForName(brief.authorName)}
- )}
-
+
{brief.authorName}
) : null}
diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
index c65890e7a9..00df5ee5f1 100644
--- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
+++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx
@@ -118,6 +118,7 @@ export function TaskChatRunnerTurn({
runId,
agentName,
agentIcon,
+ agent,
items,
status,
startedAtMs,
@@ -131,6 +132,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;
@@ -213,7 +215,7 @@ export function TaskChatRunnerTurn({
data-testid="task-chat-runner-identity-row"
>
{agentName ? (
-
+
) : null}
}
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx
index 1ac28528fd..52d900ecbf 100644
--- a/ui/src/components/task-chat/TaskChatThreadView.tsx
+++ b/ui/src/components/task-chat/TaskChatThreadView.tsx
@@ -111,6 +111,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 ? (
diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx
index 8545e892f0..3377ef40ed 100644
--- a/ui/src/components/task-chat/TaskChatTurn.tsx
+++ b/ui/src/components/task-chat/TaskChatTurn.tsx
@@ -96,7 +96,7 @@ export function TaskChatTurn({
{item.agentName ? (
) : null}
diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts
index ce8fef7263..d8434c4ad3 100644
--- a/ui/src/components/task-chat/task-chat-adapter.ts
+++ b/ui/src/components/task-chat/task-chat-adapter.ts
@@ -120,6 +120,7 @@ export function commentsToTaskChatItems(
kind: "message",
author: kind,
authorName,
+ agent: effectiveAgentId(comment) ? ctx.agentMap?.get(effectiveAgentId(comment)!) ?? { id: effectiveAgentId(comment)! } : undefined,
text: comment.body,
sourceChannel: kind === "human" ? comment.metadata?.sourceChannel : undefined,
timestamp: formatTaskChatCommentTimestamp(comment, kind),
diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts
index 057fa7799f..9dbab8ada1 100644
--- a/ui/src/components/task-chat/task-chat-model.ts
+++ b/ui/src/components/task-chat/task-chat-model.ts
@@ -126,6 +126,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
@@ -506,6 +507,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.
diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx
index 8862a1196f..31470558b9 100644
--- a/ui/src/components/timeline/WorkTimelineChart.test.tsx
+++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx
@@ -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("[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");
});
diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx
index 17c9804b21..b982a51da9 100644
--- a/ui/src/components/timeline/WorkTimelineChart.tsx
+++ b/ui/src/components/timeline/WorkTimelineChart.tsx
@@ -1,3 +1,5 @@
+import { agentAvatarUrl } from "@/lib/agent-avatar-url";
+import { resolveAgentAppearance } from "@paperclipai/shared";
/**
* Work Timeline — custom-SVG Gantt (board-locked Direction C, PAP-12422).
*
@@ -12,7 +14,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 +183,10 @@ function ActorGlyph({
clipId: string;
}) {
if (actor.type === "agent") {
- const Icon = getAgentIcon(actor.avatar);
- const size = r > 10 ? 16 : 13;
- return (
-
- );
+ const size = r > 10 ? 24 : 16;
+ const appearance = resolveAgentAppearance(actor.appearance, actor.id.replace(/^agent:/, ""));
+ return ;
}
const stroke = "var(--color-foreground)";
diff --git a/ui/src/connect-flow-preview-main.tsx b/ui/src/connect-flow-preview-main.tsx
index 9b6aa7510d..221b0ce3c1 100644
--- a/ui/src/connect-flow-preview-main.tsx
+++ b/ui/src/connect-flow-preview-main.tsx
@@ -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({
diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx
index e5e998dd5d..8ab534b0f1 100644
--- a/ui/src/features/connections/ConnectionSetupFlow.tsx
+++ b/ui/src/features/connections/ConnectionSetupFlow.tsx
@@ -56,7 +56,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";
diff --git a/ui/src/hooks/useAgentAppearanceDraft.ts b/ui/src/hooks/useAgentAppearanceDraft.ts
new file mode 100644
index 0000000000..8cbd6bc85a
--- /dev/null
+++ b/ui/src/hooks/useAgentAppearanceDraft.ts
@@ -0,0 +1,19 @@
+import { useState } from "react";
+import { agentAppearanceSchema, randomAgentAppearance } from "@paperclipai/shared";
+
+/** Non-secret visual identity, retained across navigation and company changes. */
+export function useAgentAppearanceDraft(draftKey: string) {
+ const key = `paperclip.agent-appearance.${draftKey}`;
+ function readDraft() {
+ try {
+ const stored = agentAppearanceSchema.safeParse(JSON.parse(sessionStorage.getItem(key) ?? "null"));
+ if (stored.success) return stored.data;
+ } catch { /* Storage can be unavailable; retain the in-memory assignment. */ }
+ const value = randomAgentAppearance();
+ try { sessionStorage.setItem(key, JSON.stringify(value)); } catch { /* In-memory draft still works. */ }
+ return value;
+ }
+ const [draft, setDraft] = useState(() => ({ key, appearance: readDraft() }));
+ if (draft.key !== key) setDraft({ key, appearance: readDraft() });
+ return { appearance: draft.appearance, clear() { try { sessionStorage.removeItem(key); } catch { /* Best effort. */ } } };
+}
diff --git a/ui/src/index.css b/ui/src/index.css
index 545c0c0ba5..7dbf1df614 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -3047,3 +3047,43 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.chat-connection-health-summary::-webkit-details-marker {
display: none;
}
+
+/* Immutable ClipLab cap-v1 artwork tokens. New artwork gets a new version. */
+:root {
+ --agent-cap-v1-bubblegum-sky-a: #8bd4ff;
+ --agent-cap-v1-bubblegum-sky-b: #ff51bf;
+ --agent-cap-v1-pink-lemonade-a: #ff26a8;
+ --agent-cap-v1-pink-lemonade-b: #fff78a;
+ --agent-cap-v1-orchid-peach-a: #e771ff;
+ --agent-cap-v1-orchid-peach-b: #ffd87c;
+ --agent-cap-v1-coral-mint-a: #beffe8;
+ --agent-cap-v1-coral-mint-b: #ff797b;
+ --agent-cap-v1-lime-lagoon-a: #b4ffa4;
+ --agent-cap-v1-lime-lagoon-b: #26dfff;
+ --agent-cap-v1-arctic-blue-a: #97fff3;
+ --agent-cap-v1-arctic-blue-b: #0084ff;
+ --agent-cap-v1-solar-flare-a: #ecca5c;
+ --agent-cap-v1-solar-flare-b: #fe3c3f;
+ --agent-cap-v1-violet-ember-a: #ff5c43;
+ --agent-cap-v1-violet-ember-b: #6262ff;
+ --agent-cap-v1-deep-tide-a: #003d60;
+ --agent-cap-v1-deep-tide-b: #32fffc;
+ --agent-cap-v1-coral-current-a: #26e2ff;
+ --agent-cap-v1-coral-current-b: #ff6666;
+ --agent-cap-v1-golden-hour-a: #ffcd4f;
+ --agent-cap-v1-golden-hour-b: #fde5ba;
+ --agent-cap-v1-tangerine-cobalt-a: #ffad32;
+ --agent-cap-v1-tangerine-cobalt-b: #4169ff;
+ --agent-cap-v1-electric-grove-a: #b4ff32;
+ --agent-cap-v1-electric-grove-b: #008d58;
+ --agent-cap-v1-flamingo-jade-a: #ff4f9a;
+ --agent-cap-v1-flamingo-jade-b: #36edaa;
+ --agent-cap-v1-cherry-pop-a: #ff69db;
+ --agent-cap-v1-cherry-pop-b: #d51c46;
+ --agent-cap-v1-turquoise-cherry-a: #37f0db;
+ --agent-cap-v1-turquoise-cherry-b: #ff4f64;
+ --agent-cap-v1-ultraviolet-tide-a: #7516cf;
+ --agent-cap-v1-ultraviolet-tide-b: #00e8d1;
+ --agent-cap-v1-muted-dream-a: #a6aaad;
+ --agent-cap-v1-muted-dream-b: #44464a;
+}
diff --git a/ui/src/lib/agent-avatar-url.ts b/ui/src/lib/agent-avatar-url.ts
new file mode 100644
index 0000000000..1df5b27e73
--- /dev/null
+++ b/ui/src/lib/agent-avatar-url.ts
@@ -0,0 +1,2 @@
+// Storybook replaces this module at build time with its packaged-image resolver.
+export { agentAvatarUrl } from "@paperclipai/shared";
diff --git a/ui/src/lib/agent-character-slot.ts b/ui/src/lib/agent-character-slot.ts
new file mode 100644
index 0000000000..ee8201e3f9
--- /dev/null
+++ b/ui/src/lib/agent-character-slot.ts
@@ -0,0 +1,9 @@
+// The app gets one live hero. Other placements retain their static image.
+let owner: symbol | null = null;
+const listeners = new Set<() => void>();
+export const characterSlot = {
+ subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
+ getSnapshot() { return owner; },
+ acquire(id: symbol) { if (owner) return false; owner = id; for (const listener of listeners) listener(); return true; },
+ release(id: symbol) { if (owner !== id) return; owner = null; for (const listener of listeners) listener(); },
+};
diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts
index 90d5e907fe..484b0a4b31 100644
--- a/ui/src/lib/company-members.ts
+++ b/ui/src/lib/company-members.ts
@@ -85,7 +85,7 @@ export function buildCompanyUserMentionOptions(
}
export function isAgentTaskTarget(
- agent: Pick
& Partial>,
+ agent: Pick & Partial>,
): boolean {
return (
agent.status !== "terminated" &&
@@ -116,7 +116,7 @@ export function buildIssueMentionOptions(
}
export function buildMarkdownMentionOptions(args: {
- agents?: Array & Partial>> | null | undefined;
+ agents?: Array & Partial>> | null | undefined;
projects?: Array> | null | undefined;
members?: CompanyUserRecord[] | null | undefined;
issues?: Array> | 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))
diff --git a/ui/src/lib/duplicate-agent-payload.test.ts b/ui/src/lib/duplicate-agent-payload.test.ts
index dd86af7a50..4af744a916 100644
--- a/ui/src/lib/duplicate-agent-payload.test.ts
+++ b/ui/src/lib/duplicate-agent-payload.test.ts
@@ -47,6 +47,11 @@ const baseAgent: AgentDetail = {
};
describe("duplicate agent payload", () => {
+ it("omits the source persona so central creation makes a fresh assignment", () => {
+ const source = { ...baseAgent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" } } as AgentDetail;
+ expect(buildDuplicateAgentPayload(source)).not.toHaveProperty("appearance");
+ });
+
it("suffixes duplicate names", () => {
expect(duplicateAgentName("Senior Product Engineer")).toBe("Senior Product Engineer Copy");
expect(duplicateAgentName(" ")).toBe("Agent Copy");
diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx
index 519e6c5254..a64210b408 100644
--- a/ui/src/pages/AgentDetail.production.tsx
+++ b/ui/src/pages/AgentDetail.production.tsx
@@ -1,3 +1,5 @@
+import { AgentCharacter } from "../components/AgentCharacter";
+import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
@@ -91,7 +93,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";
@@ -1283,14 +1284,7 @@ export function AgentDetail() {
{/* Header */}
-
updateIcon.mutate(icon)}
- >
-
-
-
-
+
{agent.name}
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 1ae0704863..fc833c2aa0 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -1,3 +1,5 @@
+import { AgentCharacter } from "../components/AgentCharacter";
+import { characterStateForAgent } from "@paperclipai/shared";
import { mergeRunLogChunks, readChunkSeq } from "../lib/run-log-chunks";
import { getPageVisibility, usePageVisibility } from "../lib/page-visibility";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
@@ -27,7 +29,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";
@@ -89,7 +90,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";
@@ -1248,7 +1248,7 @@ export function AgentDetail() {
{agent.name}
diff --git a/ui/src/pages/Agents.production.tsx b/ui/src/pages/Agents.production.tsx
index 7ba12ed9e4..a5eee50fc8 100644
--- a/ui/src/pages/Agents.production.tsx
+++ b/ui/src/pages/Agents.production.tsx
@@ -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 ? (
) : (
-
+
)}
secondaryRow={
builtInCluster ? (
@@ -655,7 +656,7 @@ function OrgTreeNode({
{hasInvalidOrgChain ? (
) : (
-
+
)}
{/* Name floor + `truncate` keeps the primary identifier readable; the
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx
index 04473b93c5..e347a4250b 100644
--- a/ui/src/pages/Agents.tsx
+++ b/ui/src/pages/Agents.tsx
@@ -1,3 +1,4 @@
+import { AgentAvatar } from "@/components/AgentAvatar";
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
@@ -400,7 +401,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
leading={hasInvalidOrgChain ? (
) : (
-
+
)}
secondaryRow={builtInCluster && (
@@ -636,7 +637,7 @@ function OrgTreeNode({
{hasInvalidOrgChain ? (
) : (
-
+
)}
{/* Name floor + `truncate` keeps the primary identifier readable; the
diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx
index 972b920f5e..9491f5b4db 100644
--- a/ui/src/pages/ApprovalDetail.tsx
+++ b/ui/src/pages/ApprovalDetail.tsx
@@ -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 && (
Requested by
- agent.id === approval.requestedByAgentId) ?? { id: approval.requestedByAgentId, name: "Agent" }}
size="sm"
/>
@@ -331,8 +332,8 @@ export function ApprovalDetail() {
{comment.authorAgentId ? (
-
agent.id === comment.authorAgentId) ?? { id: comment.authorAgentId, name: "Agent" }}
size="sm"
/>
diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx
index caf067fbe2..3901c926e9 100644
--- a/ui/src/pages/BoardChat.tsx
+++ b/ui/src/pages/BoardChat.tsx
@@ -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 (
-
-
-
- {icon ? (
-
- ) : (
- agentInitials(name)
- )}
-
-
-
{name}
-
- );
+function AgentBubbleHeader({ agent }: { agent: import("../components/AgentAvatar").AvatarAgent }) {
+ return ;
}
/** Agent-styled chat bubble containing the three-dot typing indicator. */
@@ -776,7 +763,7 @@ export function BoardChat() {
return (
<>
-
+
-
+
{ceoAgent && (
-
+
)}
-
+
{agent.name}
@@ -3446,7 +3447,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"
>
-
+
{agent.name}
{meta?.paused ? (
@@ -3906,7 +3907,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"
>
-
+
))}
diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx
index 7e713884e7..58405951c6 100644
--- a/ui/src/pages/CompanySkills.tsx
+++ b/ui/src/pages/CompanySkills.tsx
@@ -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";
@@ -36,7 +38,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";
@@ -3255,7 +3256,7 @@ export function SkillDetailPage({
const meta = attachAgentMetaById.get(agent.id);
return (
-
+
{agent.name}
@@ -3463,7 +3464,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"
>
-
+
{agent.name}
{meta?.paused ? (
@@ -3924,7 +3925,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"
>
-
+
))}
diff --git a/ui/src/pages/Costs.production.tsx b/ui/src/pages/Costs.production.tsx
index a314c5ffb6..bbb8b9a804 100644
--- a/ui/src/pages/Costs.production.tsx
+++ b/ui/src/pages/Costs.production.tsx
@@ -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() {
) : (
)}
-
+
{row.agentStatus === "terminated" ?
: null}
diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx
index c639fd6aef..e28cf08dd9 100644
--- a/ui/src/pages/Costs.tsx
+++ b/ui/src/pages/Costs.tsx
@@ -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({
) : (
)}
-
+
{row.agentStatus === "terminated" ?
: null}
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index 1033c34b98..009f74c836 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -1,3 +1,4 @@
+import { AgentIdentity } from "../components/AgentIdentity";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router";
import {
@@ -24,7 +25,6 @@ import { StatusIcon } from "../components/StatusIcon";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { ActivityRow } from "../components/ActivityRow";
-import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
@@ -537,7 +537,7 @@ export function Dashboard() {
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
- ?
+ ?
agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId ?? undefined, name }} size="sm" className="max-w-32" />
: null;
})()}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index f4afbdb786..1c3f90942a 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -5,6 +5,9 @@ import { announcementPreview, announcementAnimationPreview, announcementAnimatio
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { AiConnectionDesignExamples } from "@/components/ai-connections/AiConnectionDesignExamples";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { AgentCharacter } from "@/components/AgentCharacter";
+import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
@@ -1507,20 +1510,29 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* IDENTITY */}
{/* ============================================================ */}
-
+
+
+ {AGENT_PALETTE_IDS.map(palette =>
)}
+
+
+ Place one live character beside the agent name. Onboarding uses a larger padded frame. Onboarding and agent headers follow the pointer across the page; other placements track within their region. Full-page examples are in Storybook under Agents / Personas / Full pages.
+
+
+
+
-
-
-
+
+
+
-
+
-
+
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index c602c5c58a..59415263cb 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -2693,7 +2693,7 @@ function StreamlinedInbox() {
agent.id === issue.assigneeAgentId)}
+ creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
assigneeUserName={
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
?? assigneeUserProfile?.label
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 15d14bef1f..5576069196 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -1,3 +1,5 @@
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { AgentIdentity } from "@/components/AgentIdentity";
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
import { Settings as ChatSettings } from "lucide-react";
@@ -196,7 +198,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";
@@ -663,7 +664,7 @@ function ActorIdentity({
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
- return ;
+ return ;
}
if (evt.actorType === "system") return ;
if (evt.actorType === "user") {
@@ -680,6 +681,7 @@ function ActorIdentity({
}
export type AttributionActor = {
+ appearance?: Agent["appearance"];
kind: "agent" | "user";
id: string;
name: string;
@@ -710,36 +712,26 @@ function AttributionAvatar({
return (
-
- {actor.avatarUrl ? (
-
- ) : null}
- {attributionInitials(actor.name)}
-
+
+ {actor.kind === "agent" ? : (
+
+ {actor.avatarUrl ? : null}
+ {attributionInitials(actor.name)}
+
+ )}
+
-
- {actor.avatarUrl ? (
-
- ) : null}
-
- {attributionInitials(actor.name)}
-
-
+ {actor.kind === "agent" ?
: (
+
+ {actor.avatarUrl ? : null}
+ {attributionInitials(actor.name)}
+
+ )}
{label}
@@ -777,6 +769,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),
@@ -798,6 +791,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),
@@ -7704,7 +7698,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
? (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,
diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx
index 4f82668426..0c94ab9907 100644
--- a/ui/src/pages/LegacyInbox.tsx
+++ b/ui/src/pages/LegacyInbox.tsx
@@ -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
diff --git a/ui/src/pages/OrgChart.production.tsx b/ui/src/pages/OrgChart.production.tsx
index d394489b43..87df93b4e5 100644
--- a/ui/src/pages/OrgChart.production.tsx
+++ b/ui/src/pages/OrgChart.production.tsx
@@ -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 */}
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -2835,7 +2835,7 @@ export function PipelineSettings() {
const agent = option.id.startsWith("agent:") ? agentById.get(option.id.slice("agent:".length)) : null;
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -2916,7 +2916,7 @@ export function PipelineSettings() {
: null;
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -2927,7 +2927,7 @@ export function PipelineSettings() {
const agent = agentId ? agentById.get(agentId) : null;
return (
<>
- {agent ? : null}
+ {agent ? : null}
{option.label}
>
);
@@ -3056,7 +3056,7 @@ export function PipelineSettings() {
) : null}
-
+
{selectedAutomationAgent.name} runs this step automatically.
@@ -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}
diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx
index 926ea7f386..8c1a4ef4e4 100644
--- a/ui/src/pages/Pipelines.tsx
+++ b/ui/src/pages/Pipelines.tsx
@@ -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`}
>
-
+
{automationAgent.name}
) : null}
diff --git a/ui/src/pages/Routines.production.tsx b/ui/src/pages/Routines.production.tsx
index 4ec6f10503..691ec369d2 100644
--- a/ui/src/pages/Routines.production.tsx
+++ b/ui/src/pages/Routines.production.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -1057,7 +1057,7 @@ export function Routines() {
const assignee = agentById.get(option.id);
return (
<>
- {assignee ? : null}
+ {assignee ? : null}
{option.label}
>
);
diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx
index e7c03b1542..978a7e78ea 100644
--- a/ui/src/pages/Routines.tsx
+++ b/ui/src/pages/Routines.tsx
@@ -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 ? (
<>
-
+
{option.label}
>
) : (
@@ -1090,7 +1090,7 @@ export function Routines() {
const assignee = agentById.get(option.id);
return (
<>
- {assignee ? : null}
+ {assignee ? : null}
{option.label}
>
);
diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx
index b4cc5a4c59..d1e42436df 100644
--- a/ui/src/pages/Search.tsx
+++ b/ui/src/pages/Search.tsx
@@ -328,8 +328,8 @@ export function Search() {
placeholderData: (previousData) => previousData,
});
- const agentsById = useMemo>>(() => {
- const map = new Map>();
+ const agentsById = useMemo>>(() => {
+ const map = new Map>();
for (const agent of agents) map.set(agent.id, agent);
return map;
}, [agents]);
@@ -732,7 +732,7 @@ interface SearchTabContentProps {
sortLabel: string;
zeroResultsSlot: ReactNode;
isFetching: boolean;
- agentsById: ReadonlyMap>;
+ agentsById: ReadonlyMap>;
}
function SearchTabContent({
diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx
index 112bd5539f..f5a07cf30f 100644
--- a/ui/src/pages/SkillStudio.tsx
+++ b/ui/src/pages/SkillStudio.tsx
@@ -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 {
@@ -2958,7 +2959,7 @@ function AgentPicker({
{selectedAgent ? (
-
+
) : (
Pick an agent
)}
@@ -2991,7 +2992,7 @@ function AgentPicker({
)}
aria-hidden
/>
-
+
{!selectable && (
Paused
@@ -3118,7 +3119,7 @@ function RunDetailView({
-
+
{removed &&
removed }
v{detail.skillVersion.revisionNumber}
diff --git a/ui/src/pages/StatusCards/SummarizerAgentSelect.tsx b/ui/src/pages/StatusCards/SummarizerAgentSelect.tsx
index c738a22c8e..1c97b1fe52 100644
--- a/ui/src/pages/StatusCards/SummarizerAgentSelect.tsx
+++ b/ui/src/pages/StatusCards/SummarizerAgentSelect.tsx
@@ -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 ? : null}
+ {agent ? : null}
{option.label}
>
);
diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
index b0d64ad447..24c1454120 100644
--- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
+++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
@@ -1,8 +1,8 @@
+import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useMemo, useRef, useState, type ReactNode } 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";
@@ -183,7 +183,7 @@ function AgentAccessSection({
{selectedAgents.map((agent) => (
))}
diff --git a/ui/src/pages/audit/AuditFeed.production.tsx b/ui/src/pages/audit/AuditFeed.production.tsx
index 94426bfcda..375f112824 100644
--- a/ui/src/pages/audit/AuditFeed.production.tsx
+++ b/ui/src/pages/audit/AuditFeed.production.tsx
@@ -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 (
-
+
{agent.name}
diff --git a/ui/src/pages/audit/AuditFeed.tsx b/ui/src/pages/audit/AuditFeed.tsx
index 2637f161d4..525ddc90a8 100644
--- a/ui/src/pages/audit/AuditFeed.tsx
+++ b/ui/src/pages/audit/AuditFeed.tsx
@@ -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 (
-
+
{agent.name}
diff --git a/ui/src/pages/secrets/proposal-review.tsx b/ui/src/pages/secrets/proposal-review.tsx
index a5af79499c..7b79a07012 100644
--- a/ui/src/pages/secrets/proposal-review.tsx
+++ b/ui/src/pages/secrets/proposal-review.tsx
@@ -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 (
-
+
{agent.name}
);
diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts
index 0192e08658..0357165753 100644
--- a/ui/storybook/.storybook/main.ts
+++ b/ui/storybook/.storybook/main.ts
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import type { StorybookConfig } from "@storybook/react-vite";
import tailwindcss from "@tailwindcss/vite";
import { mergeConfig } from "vite";
+import { storybookAgentAvatarAssets } from "../../../scripts/storybook-agent-avatar-assets.mjs";
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
@@ -17,9 +18,10 @@ const config: StorybookConfig = {
docs: {
autodocs: true,
},
- viteFinal: async (baseConfig) =>
+ viteFinal: async (baseConfig, { configType }) =>
mergeConfig(baseConfig, {
- plugins: [tailwindcss()],
+ plugins: [tailwindcss(), storybookAgentAvatarAssets()],
+ server: { proxy: { "/api/agent-avatars": { target: process.env.PAPERCLIP_STORYBOOK_API_URL ?? "http://localhost:3100", changeOrigin: true } } },
optimizeDeps: { include: ["motion/react", "react", "react-dom"] },
resolve: {
// Storybook's core and the react-vite builder each resolve their own
@@ -29,6 +31,9 @@ const config: StorybookConfig = {
// The app's own dev server hoists one React and never hit this.
dedupe: ["react", "react-dom"],
alias: {
+ ...(configType === "PRODUCTION" ? {
+ "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"),
+ } : {}),
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"),
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
diff --git a/ui/storybook/fixtures/agent-avatar-url.test.ts b/ui/storybook/fixtures/agent-avatar-url.test.ts
new file mode 100644
index 0000000000..f1aaebf8d0
--- /dev/null
+++ b/ui/storybook/fixtures/agent-avatar-url.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { AGENT_AVATAR_SIZES, AGENT_PALETTE_IDS, CHARACTER_STATES, appearanceForPalette } from "@paperclipai/shared";
+import { agentAvatarUrl as productionUrl } from "../../src/lib/agent-avatar-url";
+import { agentAvatarUrl } from "./agent-avatar-url";
+
+describe("packaged Storybook avatar URLs", () => {
+ it("keeps production on the public API and resolves preview assets under a branch prefix", () => {
+ const appearance = appearanceForPalette("bubblegum-sky");
+ expect(productionUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/bubblegum-sky/rest.png?size=24&scale=2");
+ expect(new URL(agentAvatarUrl(appearance, 24, 2), "https://example.com/branches/personas/builds/123/iframe.html").href)
+ .toBe("https://example.com/branches/personas/builds/123/agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png");
+ expect(agentAvatarUrl(appearance)).toBe("./agent-avatar-images/cap-v1/bubblegum-sky/rest-512-1.png");
+ });
+
+ it("gives every finite preset a distinct filename, retaining logical size separately from density", () => {
+ const paths = new Set
();
+ for (const palette of AGENT_PALETTE_IDS) for (const pose of CHARACTER_STATES)
+ for (const size of AGENT_AVATAR_SIZES) for (const scale of [1, 2] as const) {
+ paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose));
+ paths.add(agentAvatarUrl(appearanceForPalette(palette), size, scale, pose, true));
+ }
+ expect(paths.size).toBe((AGENT_PALETTE_IDS.length + 1) * CHARACTER_STATES.length * AGENT_AVATAR_SIZES.length * 2);
+ expect(agentAvatarUrl(appearanceForPalette("arctic-blue"), 24, 2))
+ .not.toBe(agentAvatarUrl(appearanceForPalette("arctic-blue"), 48, 1));
+ expect([...paths].every(path => !path.includes("?") && path.startsWith("./agent-avatar-images/cap-v1/"))).toBe(true);
+ });
+});
diff --git a/ui/storybook/fixtures/agent-avatar-url.ts b/ui/storybook/fixtures/agent-avatar-url.ts
new file mode 100644
index 0000000000..efbe5c3ce5
--- /dev/null
+++ b/ui/storybook/fixtures/agent-avatar-url.ts
@@ -0,0 +1,8 @@
+import { agentAvatarUrl as apiAvatarUrl } from "@paperclipai/shared";
+
+/** Relative to iframe.html, including when a preview is hosted under a branch prefix. */
+export const agentAvatarUrl: typeof apiAvatarUrl = (...args) => {
+ const url = new URL(apiAvatarUrl(...args), "https://storybook.invalid");
+ const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, "");
+ return `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}.png`;
+};
diff --git a/ui/storybook/stories/agent-persona-pages.stories.tsx b/ui/storybook/stories/agent-persona-pages.stories.tsx
new file mode 100644
index 0000000000..0ae8a72759
--- /dev/null
+++ b/ui/storybook/stories/agent-persona-pages.stories.tsx
@@ -0,0 +1,120 @@
+import { agentAvatarUrl } from "@/lib/agent-avatar-url";
+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 } 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 liveRuns = storybookLiveRuns.map(run => ({ ...run, agentAppearance: agents.find(agent => agent.id === run.agentId)?.appearance }));
+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(liveRuns);
+ 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. I’ve 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(null);
+ useEffect(() => installPageFixtures(), []);
+ useEffect(() => {
+ if (initialPath.current === path) return;
+ initialPath.current = path;
+ queryClient.setQueryData(queryKeys.agents.list(companyId), agents);
+ // Shared polling elects a leader asynchronously; seed the visible panel so
+ // its initial render is independent of that election and the avatar cache.
+ queryClient.setQueryData([...queryKeys.liveRuns(companyId), "dashboard", { minRunCount: 4, fetchLimit: undefined }], liveRuns);
+ 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
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {meet && setDialogOpen(false)} onContinue={basics => { setDialogOpen(false); navigate(`/PAP/agents/new?name=${encodeURIComponent(basics.name)}&adapterType=${basics.adapterType}`); }} />}
+ ;
+}
+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: () => };
+export const AgentOverview: Story = { render: () => };
+export const Task: Story = { render: () => };
+export const CompanyDashboard: Story = { name: "Dashboard", render: () => };
+export const MeetYourNextAgent: Story = { render: () => };
+export const NewAgentConnection: Story = { render: () => };
diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agent-personas.stories.tsx
new file mode 100644
index 0000000000..007972cf98
--- /dev/null
+++ b/ui/storybook/stories/agent-personas.stories.tsx
@@ -0,0 +1,130 @@
+import { agentAvatarUrl } from "@/lib/agent-avatar-url";
+import { expect, waitFor } from "storybook/test";
+import { useEffect, useRef, useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type CharacterState } from "@paperclipai/shared";
+import { AgentAvatar, avatarSizeClasses } from "../../src/components/AgentAvatar";
+import { AgentCharacter } from "../../src/components/AgentCharacter";
+import { AgentIdentity } from "../../src/components/AgentIdentity";
+import { Button } from "../../src/components/ui/button";
+
+const appearance = appearanceForPalette("bubblegum-sky");
+const agent = { id: "storybook-agent", name: "Chief of Staff", appearance };
+const meta = {
+ title: "Agents/Personas",
+ component: AgentCharacter,
+ args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
+ parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Development Storybook uses PAPERCLIP_STORYBOOK_API_URL. Published Storybook packages PNGs from the same API renderer automatically during its build, including every preset and both densities; no running API is required." } } },
+ argTypes: {
+ state: { control: "select", options: CHARACTER_STATES },
+ size: { control: "select", options: AGENT_AVATAR_SIZES },
+ motion: { control: "select", options: ["auto", "still"] },
+ trackingScope: { control: "select", options: ["region", "page"] },
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const LiveCharacter: Story = {};
+export const ReducedMotion: Story = { args: { motion: "still" } };
+export const GrayBeforeConnection: Story = { args: { muted: true, state: "sleepy" } };
+export const Palettes: Story = {
+ render: () => {AGENT_PALETTE_IDS.map(palette =>
)}
,
+};
+export const Sizes: Story = {
+ render: () => {AGENT_AVATAR_SIZES.map(size =>
)}
,
+};
+export const Expressions: Story = {
+ render: () => {CHARACTER_STATES.map(state =>
)}
,
+};
+export const LightAndDark: Story = {
+ render: () => {["light", "dark"].map(theme =>
)}
,
+};
+export const StaticAndLive: Story = {
+ render: () => ,
+};
+function Onboarding() {
+ const [state, setState] = useState("sleepy");
+ const region = useRef(null);
+ return
+
+
{state === "success" ? "Ready to work" : "Connect your agent"}
+
setState("loading")}>Connecting setState("success")}>Connection succeeded setState("sleepy")}>Reset
+
;
+}
+export const OnboardingTransition: Story = { render: () => };
+export const AppPlacements: Story = {
+ render: () =>
+
Agents {AGENT_PALETTE_IDS.slice(0, 4).map((palette, i) => )}
+
Task conversation The launch brief is ready for review.
+
+
Agent configuration Chief of Staff · Connected
+
,
+};
+export const FiveHundredStaticAvatars: Story = {
+ render: () => {Array.from({ length: 500 }, (_, i) =>
)}
,
+};
+export const LegacyIdentity: Story = { render: () => };
+function Lifecycle() {
+ const [mounted, setMounted] = useState(true);
+ return setMounted(value => !value)}>Toggle character {mounted &&
}
;
+}
+export const MountAndUnmount: Story = { render: () => };
+export const OneLiveRenderer: Story = { render: () => };
+export const ImageFailure: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ // Exercise the actual image error handler without a custom product URL API.
+ canvasElement.querySelector("img")?.dispatchEvent(new Event("error"));
+ await waitFor(() => expect(canvasElement.textContent).toContain("CS"));
+ },
+};
+export const CacheMissLoading: Story = {
+ render: () => The image slot keeps its dimensions while Paperclip renders a cold cache entry. ,
+};
+function WebGLFailure() {
+ const region = useRef(null);
+ return
+
region.current?.querySelector("canvas")?.dispatchEvent(new Event("webglcontextlost", { cancelable: true }))}>Simulate WebGL loss
+
;
+}
+export const RenderFailure: Story = { render: () => };
+export const PointerScope: Story = {
+ render: () => Pointer movement outside the connection panel does not move the character. Touch and reduced motion disable tracking.
,
+};
+
+// A seeked WebGL frame lets Linux visual tests compare the identical front-facing
+// sample with the server SVG/raster path. It schedules no animation frames.
+type SnapshotPairProps = { size?: typeof AGENT_AVATAR_SIZES[number]; state?: CharacterState; density?: 1 | 2 };
+function SnapshotPair({ size = 256, state = "rest", density = 2 }: SnapshotPairProps) {
+ const host = useRef(null);
+ useEffect(() => {
+ let disposed = false;
+ let cleanup: (() => void) | undefined;
+ void Promise.all([import("@paperclipai/shared/cliplab/renderer"), import("@paperclipai/shared/cliplab/definition")]).then(([{ CharacterRenderer }, library]) => {
+ if (disposed || !host.current) return;
+ const canvas = document.createElement("canvas");
+ canvas.className = "size-full";
+ const renderer = new CharacterRenderer(canvas, { width: size, height: size, displaySize: size, pixelRatio: density });
+ const definition = library.characterDefinition(appearance);
+ renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false }, library.characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } });
+ host.current.appendChild(canvas);
+ cleanup = () => { renderer.dispose(); canvas.remove(); };
+ });
+ return () => { disposed = true; cleanup?.(); };
+ }, [size, state, density]);
+ return
+
PNG · {size * density} × {size * density}
+
WebGL · {size * density} × {size * density}
+
;
+}
+export const SnapshotAgreement = { args: { size: 256, state: "rest", density: 2 }, argTypes: { density: { control: "inline-radio", options: [1, 2] } }, render: (args: SnapshotPairProps) => } satisfies StoryObj;