diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx index 73daea7f2d..627f399f42 100644 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -49,8 +49,8 @@ function renderChart( function timelineSample(): WorkTimelineResult { return { actors: [ - { id: "agent:codex", type: "agent", name: "CodexCoder" }, - { id: "agent:qa", type: "agent", name: "QA" }, + { id: "agent:codex", type: "agent", name: "CodexCoder", avatar: "code" }, + { id: "agent:qa", type: "agent", name: "QA", avatar: "shield" }, ], spans: [ { @@ -148,6 +148,16 @@ describe("WorkTimelineChart", () => { expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); }); + it("renders configured agent icons in the actor gutter instead of generated initials", () => { + 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(); + expect(gutter?.textContent).not.toContain("CC"); + }); + it("does not render created diamonds or comment bubbles from instant events", () => { const data = timelineSample(); data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); @@ -248,9 +258,14 @@ describe("WorkTimelineChart", () => { expect(layout.connectors[0].x2).toBe(bars.get("run-2")?.x1); }); - it("renders kickoff chips for human users but not delegating agents", () => { + it("renders kickoff chips with human avatar images but not delegating agents", () => { const data = timelineSample(); - data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); + data.actors.push({ + id: "user:dotta", + type: "user", + name: "Dotta", + avatar: "/api/assets/dotta-avatar/content", + }); data.edges = [ { fromActorId: "user:dotta", @@ -272,7 +287,8 @@ describe("WorkTimelineChart", () => { const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']"); expect(kickoffChips).toHaveLength(1); - expect(kickoffChips[0].textContent).toContain("DO"); + expect(kickoffChips[0].querySelector("image")?.getAttribute("href")).toBe("/api/assets/dotta-avatar/content"); + expect(kickoffChips[0].textContent).not.toContain("DO"); }); it("reserves normal wheel input for panning and uses modifier-wheel for continuous zoom", () => { diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index 689f6ead78..932a488611 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -12,6 +12,7 @@ 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, @@ -134,25 +135,70 @@ function truncate(text: string, n = 42): string { return text.length > n ? `${text.slice(0, n - 1)}…` : text; } -/** An SVG avatar glyph: square for humans, dashed circle for system, circle for agents. */ -function AvatarGlyph({ +function svgFragmentId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "-"); +} + +/** An SVG avatar glyph: agents use their configured sidebar icon, humans use their avatar image. */ +function ActorGlyph({ + actor, cx, cy, r, - label, - type, + clipId, }: { + actor: WorkTimelineActor; cx: number; cy: number; r: number; - label: string; - type: string; + clipId: string; }) { + if (actor.type === "agent") { + const Icon = getAgentIcon(actor.avatar); + const size = r > 10 ? 16 : 13; + return ( + + ); + } + const stroke = "var(--color-foreground)"; - const fill = type === "system" ? "var(--color-muted)" : "var(--color-card)"; + const fill = actor.type === "system" ? "var(--color-muted)" : "var(--color-card)"; + const label = shortLabel(actor.name); + + if (actor.type === "user" && actor.avatar) { + return ( + + + + + + + + + + ); + } + return ( - {type === "user" ? ( + {actor.type === "user" ? ( ) : ( )} 10 ? 9 : 8} textAnchor="middle" fill={stroke}> @@ -483,9 +529,10 @@ export function WorkTimelineChart({ {/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */} {layout.rows.map((row) => { const cy = row.y + AXIS_H + row.h / 2; + const actorGlyphId = svgFragmentId(`plot-${row.actor.id}`); return ( - + {truncate(row.actor.name, 18)} @@ -561,12 +608,12 @@ export function WorkTimelineChart({ {bar.kickoff && actorType(bar.kickoff) === "user" && ( - )} @@ -623,6 +670,7 @@ function ActorGutter({ rows, height }: { rows: ReturnType[ {rows.map((row, i) => { const cy = row.y + AXIS_H + row.h / 2; + const actorGlyphId = svgFragmentId(`gutter-${row.actor.id}`); return ( [ fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"} opacity={i % 2 ? 0.35 : 1} /> - + {truncate(row.actor.name, 16)}