[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:
parent
f40a8fbf1e
commit
59092e85d5
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue