From 59092e85d56c32a6e6ac776b7e89a5a6aad4a894 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:24:33 -0500 Subject: [PATCH] [codex] Fix work timeline actor avatars (#9152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The work timeline UI visualizes agent and human activity across kickoff chips and the Gantt chart. > - The timeline actor chips were falling back to generic initials or blank circular avatars instead of using the richer identity data already available elsewhere in the app. > - Agent rows should match the sidebar identity treatment, and human entries should use the user's configured avatar image when one exists. > - This pull request teaches the timeline avatar renderer to prefer configured agent icons and human avatar URLs, while preserving initials as a fallback. > - The benefit is a more recognizable work timeline that matches the rest of the Paperclip UI. ## Linked Issues or Issue Description No public GitHub issue exists for this UI bug. The underlying issue: work timeline actor avatars did not consistently use the available actor identity assets. Agents appeared as generic white-circle initials instead of their configured sidebar icons, and human kickoff entries appeared as initials even when the user had an avatar image. Related prior timeline work: #8875 and #8880. No open duplicate PR was found for this avatar correction. ## What Changed - Updated the work timeline actor avatar renderer to show configured agent icons for agent rows and chips. - Updated human kickoff avatar rendering to prefer the user avatar image and fall back to initials only when no image is available. - Added regression coverage for agent sidebar-style icons and human avatar images in `WorkTimelineChart`. ## Verification - `pnpm exec vitest run ui/src/components/timeline/WorkTimelineChart.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `git diff --check origin/master...HEAD` ## Risks Low risk. The change is scoped to work timeline avatar presentation and preserves the existing initials fallback when configured icons or avatar images are unavailable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-class coding agent in local tool-use mode with shell, git, test execution, and GitHub connector access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../timeline/WorkTimelineChart.test.tsx | 26 +++++-- .../components/timeline/WorkTimelineChart.tsx | 76 +++++++++++++++---- 2 files changed, 83 insertions(+), 19 deletions(-) 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)}