diff --git a/tests/storybook-visual/agent-personas.config.ts b/tests/storybook-visual/agent-personas.config.ts new file mode 100644 index 0000000000..0aeaae0e57 --- /dev/null +++ b/tests/storybook-visual/agent-personas.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: ".", testMatch: "agent-personas.spec.ts", workers: 1, retries: 0, timeout: 45_000, + outputDir: "./test-results/agent-personas", reporter: [["list"]], + snapshotPathTemplate: "{testDir}/.snapshots/agent-personas/{arg}{ext}", + use: { + browserName: "chromium", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017", + viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1, reducedMotion: "reduce", + launchOptions: { args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] }, + }, +}); diff --git a/tests/storybook-visual/agent-personas.spec.ts b/tests/storybook-visual/agent-personas.spec.ts new file mode 100644 index 0000000000..aced7e89dc --- /dev/null +++ b/tests/storybook-visual/agent-personas.spec.ts @@ -0,0 +1,155 @@ +import { test, expect, type Page } from "@playwright/test"; +async function story(page: Page, id: string, args = "", theme = "dark") { + await page.goto(`/iframe.html?id=agents-personas--${id}&viewMode=story&globals=theme:${theme}${args ? `&args=${args}` : ""}`); + await page.locator("#storybook-root").waitFor(); + await expect.poll(() => page.locator("#storybook-root").evaluate(element => element.childElementCount)).toBeGreaterThan(0); +} +async function imagesLoaded(page: Page) { + await page.locator("#storybook-root img").evaluateAll(images => images.forEach(image => image.setAttribute("loading", "eager"))); + await expect.poll(() => page.locator("#storybook-root img").evaluateAll(images => images.every(image => (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0))).toBe(true); +} +for (const theme of ["light", "dark"]) { + for (const id of ["palettes", "sizes", "expressions", "app-placements", "gray-before-connection"]) { + test(`${id} on ${theme}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await story(page, id, "", theme); await imagesLoaded(page); + expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); + await expect(page.locator("canvas")).toHaveCount(0); + await expect(page.locator("#storybook-root")).toHaveScreenshot(`${id}-${theme}.png`, { animations: "disabled", maxDiffPixels: 0 }); + }); + } +} +test("500 avatars load images without WebGL, live modules or avatar frame loops", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.addInitScript(() => { + (window as any).__personaProbe = { webgl: 0, frames: 0 }; + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function (kind: string, ...args: any[]) { + if (kind.includes("webgl")) (window as any).__personaProbe.webgl++; + return (getContext as any).call(this, kind, ...args); + } as any; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = callback => { + if (/\/(runtime|renderer)-/.test(new Error().stack ?? "")) (window as any).__personaProbe.frames++; + return raf(callback); + }; + }); + const liveDownloads: string[] = []; + page.on("request", req => { if (/\/(runtime|renderer)-[^/]+\.js/.test(req.url())) liveDownloads.push(req.url()); }); + await story(page, "five-hundred-static-avatars"); await imagesLoaded(page); + await expect(page.locator("#storybook-root img")).toHaveCount(500); + await expect(page.locator("canvas")).toHaveCount(0); + expect(await page.evaluate(() => (window as any).__personaProbe)).toEqual({ webgl: 0, frames: 0 }); + expect(liveDownloads).toEqual([]); +}); +test("image failures and slow cold responses preserve dimensions", async ({ page }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/api/agent-avatars/**", async route => { await held; await route.abort(); }); + await story(page, "cache-miss-loading"); + const image = page.locator("#storybook-root img"); + const before = await image.boundingBox(); + expect(before?.width).toBe(64); expect(before?.height).toBe(64); + release(); + await expect(page.locator("#storybook-root")).toContainText("CS"); + const fallback = page.locator("#storybook-root span").filter({ hasText: /^CS$/ }).first(); + expect((await fallback.boundingBox())?.width).toBe(64); +}); +test("one live renderer and static fallback after context loss", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await story(page, "one-live-renderer"); + await expect(page.locator("canvas")).toHaveCount(1); + await story(page, "render-failure"); + await expect(page.locator("canvas")).toHaveCount(1); + await page.getByRole("button", { name: "Simulate WebGL loss" }).click(); + await expect(page.locator("canvas")).toHaveCount(0); await imagesLoaded(page); +}); +test("repeated mounting releases the WebGL canvas", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await story(page, "mount-and-unmount"); + for (let i = 0; i < 3; i++) { + await expect(page.locator("canvas")).toHaveCount(1); + await page.getByRole("button", { name: "Toggle character" }).click(); + await expect(page.locator("canvas")).toHaveCount(0); + await page.getByRole("button", { name: "Toggle character" }).click(); + } +}); +const snapshotCases = [ + ...[16, 24, 48, 256].flatMap(size => [1, 2].map(density => ({ size, density, state: "rest" }))), + ...["idle", "listening", "thinking", "working", "success", "confused", "sleepy", "loading"].map(state => ({ size: 128, density: 2, state })), +]; +for (const { size, density, state } of snapshotCases) { + test(`front-facing SVG and WebGL ${state} at ${size}px density ${density}`, async ({ browser }) => { + const context = await browser.newContext({ deviceScaleFactor: density, reducedMotion: "reduce", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017" }); + const page = await context.newPage(); + await story(page, "snapshot-agreement", `size:${size};state:${state};density:${density}`); await imagesLoaded(page); + await expect(page.locator("canvas")).toHaveCount(1); + await expect(page.locator("#storybook-root")).toHaveScreenshot(`snapshot-pair-${size}-${density}-${state}.png`, { scale: "device", maxDiffPixels: 0 }); + // Same transparent silhouette. Small antialiasing differences are expected + // between sharp's SVG rasterizer and WebGL's multisample rasterizer. + const difference = await page.evaluate(async () => { + const image = document.querySelector('#storybook-root img') as HTMLImageElement; + const live = document.querySelector('#live-frame canvas, [data-testid="live-frame"] canvas') as HTMLCanvasElement; + const width = live.width, height = live.height; + const a = document.createElement("canvas"); a.width = width; a.height = height; + const b = document.createElement("canvas"); b.width = width; b.height = height; + const ac = a.getContext("2d")!, bc = b.getContext("2d")!; + ac.drawImage(image, 0, 0, width, height); bc.drawImage(live, 0, 0, width, height); + const aa = ac.getImageData(0, 0, width, height).data, bb = bc.getImageData(0, 0, width, height).data; + let silhouette = 0, colorError = 0, opaque = 0; + for (let i = 0; i < aa.length; i += 4) { + if ((aa[i + 3] > 128) !== (bb[i + 3] > 128)) silhouette++; + if (aa[i + 3] > 240 && bb[i + 3] > 240) { for (let c = 0; c < 3; c++) colorError += Math.abs(aa[i + c] - bb[i + c]); opaque++; } + } + return { silhouette: silhouette / (width * height), meanColorError: colorError / (opaque * 3) }; + }); + expect(difference.silhouette).toBeLessThan(0.04); + expect(difference.meanColorError).toBeLessThan(20); + await context.close(); + }); +} + +const fullPages = ["all-agents", "agent-overview", "task", "company-dashboard", "meet-your-next-agent", "new-agent-connection"]; +for (const id of fullPages) { + test(`full app page: ${id}`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.clock.setFixedTime(new Date("2026-09-10T18:00:00Z")); + await page.goto(`/iframe.html?id=agents-personas-full-pages--${id}&viewMode=story&globals=theme:dark`); + await expect(page.locator("main")).toBeVisible(); + await expect(page.locator("main")).not.toContainText("This page hit an error"); + await expect(page.locator('img[src*="/api/agent-avatars/"]').first()).toBeAttached(); + await imagesLoaded(page); + await expect(page.locator("canvas")).toHaveCount(0); + await expect(page).toHaveScreenshot(`full-page-${id}.png`, { animations: "disabled", maxDiffPixels: 0 }); + }); +} +for (const density of [1, 2]) { + test(`onboarding supersamples and stays inside the canvas at density ${density}`, async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: density, reducedMotion: "no-preference", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://127.0.0.1:6017" }); + const page = await context.newPage(); + await page.goto("/iframe.html?id=agents-personas-full-pages--meet-your-next-agent&viewMode=story"); + await expect(page.locator("canvas")).toHaveCount(1); + const canvas = page.locator("canvas"); + const dimensions = await canvas.evaluate(c => ({ pixels: (c as HTMLCanvasElement).width, display: c.getBoundingClientRect().width })); + expect(dimensions.display).toBe(192); + expect(dimensions.pixels).toBe(dimensions.display * density * 2); + for (const [x, y] of [[5, 5], [1195, 5], [1195, 895], [5, 895]]) { + await page.mouse.move(x, y); + // Let gaze settle at each page corner, well outside the character region. + await page.waitForTimeout(350); + const edgeAlpha = await canvas.evaluate(c => { + const source = c as HTMLCanvasElement; + const copy = document.createElement("canvas"); copy.width = source.width; copy.height = source.height; + const ctx = copy.getContext("2d")!; ctx.drawImage(source, 0, 0); + const pixels = ctx.getImageData(0, 0, copy.width, copy.height).data; + let maximum = 0; + for (let y = 0; y < copy.height; y++) for (let x = 0; x < copy.width; x++) { + if (x < 2 || y < 2 || x >= copy.width - 2 || y >= copy.height - 2) maximum = Math.max(maximum, pixels[(y * copy.width + x) * 4 + 3]); + } + return maximum; + }); + expect(edgeAlpha).toBe(0); + } + await context.close(); + }); +} diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 4d2863655b..cf913ef005 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,3 +1,4 @@ +import type { AgentAppearance } from "@paperclipai/shared"; import type { IssueRecoveryAction } from "@paperclipai/shared"; import type { HeartbeatRun, @@ -31,6 +32,8 @@ export interface ActiveRunForIssue { createdAt: string | Date; agentId: string; agentName: string; + agentAppearance?: AgentAppearance | null; + avatarUrl?: string; adapterType: string; logBytes?: number | null; lastOutputBytes?: number | null; @@ -62,6 +65,8 @@ export interface LiveRunForIssue { createdAt: string; agentId: string; agentName: string; + agentAppearance?: AgentAppearance | null; + avatarUrl?: string; adapterType: string; logBytes?: number | null; lastOutputBytes?: number | null; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 4ae9b2263a..5b9a5d4dc8 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,3 +1,4 @@ +import { AgentIdentity } from "@/components/AgentIdentity"; import { memo, useMemo } from "react"; import { Link } from "@/lib/router"; import { useQueries, useQuery } from "@tanstack/react-query"; @@ -200,7 +201,7 @@ const AgentRunCard = memo(function AgentRunCard({ ) : ( )} - +
{(run.execution?.phase === "reconnecting" || run.execution?.phase === "retry_scheduled") ? "Reconnecting…" : (isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`)} diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 90263a7c65..9c061f64c9 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -1,3 +1,4 @@ +import { AgentAvatar } from "@/components/AgentAvatar"; import { useMemo, useState, useRef, useCallback, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useVisibilityRefetchInterval } from "@/lib/polling"; @@ -26,7 +27,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { ListFilter, Layers, ChevronDown, ChevronRight, User, Settings } from "lucide-react"; -import { AgentIcon } from "./AgentIconPicker"; import { timeAgo } from "../lib/timeAgo"; /* ------------------------------------------------------------------ */ @@ -280,7 +280,7 @@ function CollapsedFeedGroup({ : } {group.latestEvent.actorType === "agent" - ? + ? : group.latestEvent.actorType === "user" ? : diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx index 13d335e854..ea0f3fd558 100644 --- a/ui/src/components/ActivityRow.tsx +++ b/ui/src/components/ActivityRow.tsx @@ -1,3 +1,4 @@ +import { AgentAvatar } from "./AgentAvatar"; import { Link } from "@/lib/router"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { deriveInitials } from "./Identity"; @@ -55,10 +56,10 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
- + {event.actorType === "agent" ? : {actorAvatarUrl && } {deriveInitials(actorName)} - + }

{actorName} {verb} 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) => (
- + {agent.name}
))} 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 && (
Requested by - +
)}
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" /> : } ) : (