[codex] Fix work timeline actor avatars (#9152)

## 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
This commit is contained in:
Dotta 2026-07-07 07:24:33 -05:00 committed by GitHub
parent f40a8fbf1e
commit 59092e85d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 19 deletions

View File

@ -49,8 +49,8 @@ function renderChart(
function timelineSample(): WorkTimelineResult { function timelineSample(): WorkTimelineResult {
return { return {
actors: [ actors: [
{ id: "agent:codex", type: "agent", name: "CodexCoder" }, { id: "agent:codex", type: "agent", name: "CodexCoder", avatar: "code" },
{ id: "agent:qa", type: "agent", name: "QA" }, { id: "agent:qa", type: "agent", name: "QA", avatar: "shield" },
], ],
spans: [ spans: [
{ {
@ -148,6 +148,16 @@ describe("WorkTimelineChart", () => {
expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); 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<SVGSVGElement>("[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", () => { it("does not render created diamonds or comment bubbles from instant events", () => {
const data = timelineSample(); const data = timelineSample();
data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); 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); 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(); 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 = [ data.edges = [
{ {
fromActorId: "user:dotta", fromActorId: "user:dotta",
@ -272,7 +287,8 @@ describe("WorkTimelineChart", () => {
const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']"); const kickoffChips = container.querySelectorAll("[data-testid='timeline-kickoff-chip']");
expect(kickoffChips).toHaveLength(1); 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", () => { it("reserves normal wheel input for panning and uses modifier-wheel for continuous zoom", () => {

View File

@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "@/lib/router"; import { useLocation } from "@/lib/router";
import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared"; import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared";
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes"; import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
import { getAgentIcon } from "@/lib/agent-icons";
import { import {
AXIS_H, AXIS_H,
actorType, actorType,
@ -134,25 +135,70 @@ function truncate(text: string, n = 42): string {
return text.length > n ? `${text.slice(0, n - 1)}` : text; 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 svgFragmentId(value: string): string {
function AvatarGlyph({ 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, cx,
cy, cy,
r, r,
label, clipId,
type,
}: { }: {
actor: WorkTimelineActor;
cx: number; cx: number;
cy: number; cy: number;
r: number; r: number;
label: string; clipId: string;
type: string;
}) { }) {
if (actor.type === "agent") {
const Icon = getAgentIcon(actor.avatar);
const size = r > 10 ? 16 : 13;
return (
<Icon
data-testid="timeline-agent-icon"
x={cx - size / 2}
y={cy - size / 2}
width={size}
height={size}
strokeWidth={2.2}
color="var(--color-muted-foreground)"
/>
);
}
const stroke = "var(--color-foreground)"; 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 (
<g>
<defs>
<clipPath id={clipId}>
<circle cx={cx} cy={cy} r={r} />
</clipPath>
</defs>
<image
data-testid="timeline-user-avatar-image"
href={actor.avatar}
x={cx - r}
y={cy - r}
width={2 * r}
height={2 * r}
preserveAspectRatio="xMidYMid slice"
clipPath={`url(#${clipId})`}
/>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={stroke} strokeWidth={1.2} opacity={0.5} />
</g>
);
}
return ( return (
<g> <g>
{type === "user" ? ( {actor.type === "user" ? (
<rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} rx={3} fill={fill} stroke={stroke} strokeWidth={1.5} /> <rect x={cx - r} y={cy - r} width={2 * r} height={2 * r} rx={3} fill={fill} stroke={stroke} strokeWidth={1.5} />
) : ( ) : (
<circle <circle
@ -162,7 +208,7 @@ function AvatarGlyph({
fill={fill} fill={fill}
stroke={stroke} stroke={stroke}
strokeWidth={1.5} strokeWidth={1.5}
strokeDasharray={type === "system" ? "3 2" : undefined} strokeDasharray={actor.type === "system" ? "3 2" : undefined}
/> />
)} )}
<text x={cx} y={cy + 3.4} fontSize={r > 10 ? 9 : 8} textAnchor="middle" fill={stroke}> <text x={cx} y={cy + 3.4} fontSize={r > 10 ? 9 : 8} textAnchor="middle" fill={stroke}>
@ -483,9 +529,10 @@ export function WorkTimelineChart({
{/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */} {/* rows: gutter avatar/label, lane baselines, bars, human kickoff chips */}
{layout.rows.map((row) => { {layout.rows.map((row) => {
const cy = row.y + AXIS_H + row.h / 2; const cy = row.y + AXIS_H + row.h / 2;
const actorGlyphId = svgFragmentId(`plot-${row.actor.id}`);
return ( return (
<g key={`row-${row.actor.id}`}> <g key={`row-${row.actor.id}`}>
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} /> <ActorGlyph actor={row.actor} cx={26} cy={cy} r={AVATAR_R} clipId={actorGlyphId} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)"> <text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 18)} {truncate(row.actor.name, 18)}
</text> </text>
@ -561,12 +608,12 @@ export function WorkTimelineChart({
</g> </g>
{bar.kickoff && actorType(bar.kickoff) === "user" && ( {bar.kickoff && actorType(bar.kickoff) === "user" && (
<g className="pointer-events-none" data-testid="timeline-kickoff-chip"> <g className="pointer-events-none" data-testid="timeline-kickoff-chip">
<AvatarGlyph <ActorGlyph
actor={bar.kickoff as WorkTimelineActor}
cx={bar.x1} cx={bar.x1}
cy={yTop + bar.height / 2} cy={yTop + bar.height / 2}
r={CHIP_R} r={CHIP_R}
label={shortLabel((bar.kickoff as WorkTimelineActor).name)} clipId={svgFragmentId(`kickoff-${bar.span.runId}-${bar.kickoff.id}`)}
type={actorType(bar.kickoff)}
/> />
</g> </g>
)} )}
@ -623,6 +670,7 @@ function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>[
<rect x={0} y={0} width={GEOM.gutter} height={height} fill="var(--color-card)" /> <rect x={0} y={0} width={GEOM.gutter} height={height} fill="var(--color-card)" />
{rows.map((row, i) => { {rows.map((row, i) => {
const cy = row.y + AXIS_H + row.h / 2; const cy = row.y + AXIS_H + row.h / 2;
const actorGlyphId = svgFragmentId(`gutter-${row.actor.id}`);
return ( return (
<g key={`gutter-${row.actor.id}`}> <g key={`gutter-${row.actor.id}`}>
<rect <rect
@ -633,7 +681,7 @@ function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>[
fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"} fill={i % 2 ? "var(--color-muted)" : "var(--color-card)"}
opacity={i % 2 ? 0.35 : 1} opacity={i % 2 ? 0.35 : 1}
/> />
<AvatarGlyph cx={26} cy={cy} r={AVATAR_R} label={shortLabel(row.actor.name)} type={row.actor.type} /> <ActorGlyph actor={row.actor} cx={26} cy={cy} r={AVATAR_R} clipId={actorGlyphId} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)"> <text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 16)} {truncate(row.actor.name, 16)}
</text> </text>