Files
PaperClipAI/ui/src/components/TaskList.assignees.test.tsx
T
DottaandPaperclip 952ecdbb39 feat(ui): show assignee avatars in task lists (#18607)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People use task lists to see who owns each task.
> - The shared task table shows an assignee name without a picture.
> - A picture helps people find an agent or person when they scan the
list.
> - This pull request shows the existing avatar beside the assignee
name.
> - The table uses the same image and fallback components as other
identity views.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The Assignee column in the shared task table.

**Subsystem affected**

`ui/` — the task table and its page data.

**Current behavior**

The column shows only an assignee name. People must read each name to
find an owner.

**Proposed behavior**

Show the agent avatar or human profile image beside the name when the
Assignee column is visible. Use the existing fallback if an image is
missing. Show no avatar for an unassigned task.

**Reason and benefit**

People can use the same identity pictures throughout the app. This helps
them scan task ownership.

**Breaking changes**

None. The API, task assignment, name sorting, and filters keep their
existing contracts.

**Additional context**

Related work: #2422 adds agent avatar URL support. It does not add
avatars to this table. #13319 aligns icons in task properties. This
change uses the existing identity components.

## What Changed

- Pass agent appearance and human profile images from existing company
data to the task table.
- Keep the avatar visible beside a long name as the name scrolls on
hover or focus.
- Respect the Assignee column setting and keep unassigned rows without
avatars.
- Load human photos only from Paperclip asset URLs. Show initials for
external profile URLs without an image request.
- Add tests with the real task row for agent and human images, image
fallbacks, unassigned tasks, and the Columns control.
- Use the shared human label when a directory entry is missing. The
local operator shows Board. Other missing users show the short user ID.
- Update Storybook data, design guidance, and the task-list verification
steps.

## Verification

- Current head: `c49fee5c9b`. The source-tested merge is
`586873fe47921b250ab0de18371d25632f46cff0`, which integrates master
`53645a0104`. The last commit removes only the inherited avatar
paragraph from the feature map under the current map-scope rule. Map
checks pass again. No history rewrite or duplicate feature PR.
- Reproduced the old voice-report CI failure before integration. The
unchanged test on `7f1d137a` passed on an unloaded local machine. With a
controlled 3,250 ms delay in its first local provider fixture and a
15-second wait, the old polling failed: expected 105 report requests,
received 315. This confirms the fake clock crossed refresh deadlines
during database work.
- Master already fixes this exact failure through #18628 (`d26628e35a`).
The fixed polling passes with the identical delay and wait budget. The
exact session IDs, 105 requests, one attempt per report, bounded
reconstruction, restart, and quiet assertions remain. The temporary
delay is removed.
- Final committed code: all 15 voice-report tests and 46
scheduler/coordinator tests pass against disposable embedded PostgreSQL
on macOS arm64. No external provider call is used.
- All 154 tests pass across eight focused UI suites: TaskList,
TaskList.assignees, CoordinatorTasks, IssueRow, AgentAvatar,
useTaskListCollection, useTaskListColumnPreferences, and TaskChatBubble.
- Three merge conflicts retained both current behavior and the avatar
feature: current sorting and column preferences, unread state, assignee
photos, and shared human labels. The focused tests cover those
contracts.
- UI TypeScript, UI build, token gates, map structure, Storybook catalog
generation, and whitespace checks pass. Storybook output is generated
and ignored.
- The original server shard failure remains recorded: [voice-report CI
failure](https://github.com/paperclipai/paperclip/actions/runs/41472540522/job/125369899753),
expected zero idle holds but received two. Older attempts also had
attention-retention and sandbox-runtime timeouts; their causes were not
established in those attempts. Fresh CI must qualify the merged head.
- Previous review findings were fixed with red/green coverage: missing
avatar tests, shared human labels, and arbitrary human image URLs.
Current code still limits human photos to Paperclip asset URLs; external
URLs use initials and start no image preload.
- Prior browser checks covered agent and human images, fallbacks,
unassigned rows, hidden columns, and long-name keyboard focus. Those
checks used production components with fixture data and do not prove
live backend behavior. No new browser or hosted task action was
performed for this CI repair.
- Current head `c49fee5c9b`: Greptile and Paperclip review both give 5/5
with no findings. The initial CI attempt passed the original server
shard and all other completed test suites. The workspaces-a 2/2 job was
interrupted by a runner shutdown signal ([job
log](https://github.com/paperclipai/paperclip/actions/runs/42250845315/job/128006963628));
it has no complete test result. A single bounded retry of that shard and
its dependent verification has been requested after the initial workflow
ended. No source change is based on that interruption.

- Final-head qualification: `c49fee5c9b75b695d2894933bbcbe0896aab9148`
passes all 55 applicable CI checks (three skipped, one neutral review).
[CI
result](https://github.com/paperclipai/paperclip/actions/runs/42250845315).
Greptile gives 5/5 with no open findings. Hosted task recovery remains
pending verified rollout.

## Risks

- Low risk. The change adds identity pictures through existing UI
components and existing company data.
- Human profile images hosted outside Paperclip show initials in this
table. This matches the existing task-message avatar rule.
- A long name has less width beside its avatar. The existing hover and
focus reveal keeps the name readable.
- Current master changes table sorting and preferences. Merge resolution
retains these contracts and their tests. Fresh CI and review must pass
before merge.

## Model Used

OpenAI GPT-6 through Codex assisted with code and verification. The
runtime did not expose an exact model ID or context-window size. The
agent used code execution and repository tools.

## 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 —
current head `c49fee5c9b`; the map-scope finding is fixed and its thread
is resolved
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 11:01:02 -06:00

123 lines
5.8 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appearanceForPalette } from "@paperclipai/shared";
import { createIssue } from "../../storybook/fixtures/paperclipData";
import { TaskList, type TaskListItem } from "./TaskList";
import { TooltipProvider } from "./ui/tooltip";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, state: _state, disableIssueQuicklook: _quicklook, issuePrefetch: _prefetch, ...props }:
ComponentProps<"a"> & { to?: string; state?: unknown; disableIssueQuicklook?: boolean; issuePrefetch?: unknown }) =>
<a href={to} {...props}>{children}</a>,
}));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const agentItem: TaskListItem = {
issue: createIssue({ id: "agent-task", title: "Agent task", assigneeAgentId: "agent-1", assigneeUserId: null }),
assignee: "Research Agent",
assigneeAgent: {
id: "agent-1",
name: "Research Agent",
appearance: { ...appearanceForPalette("deep-tide"), customAvatarAssetId: "22222222-2222-4222-8222-222222222222" },
},
};
const humanItem: TaskListItem = {
issue: createIssue({ id: "human-task", title: "Human task", assigneeAgentId: null, assigneeUserId: "user-1" }),
assignee: "Alex Smith",
assigneeUserAvatarUrl: "/api/assets/33333333-3333-4333-8333-333333333333/content",
};
const brokenHumanAvatar = "/api/assets/44444444-4444-4444-8444-444444444444/content";
const imageRequests = vi.fn<(src: string) => void>();
describe("TaskList assignee identities", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
imageRequests.mockClear();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
// Radix preloads profile pictures through Image. jsdom does not load them.
vi.stubGlobal("Image", class extends EventTarget {
complete = false;
naturalWidth = 0;
set src(value: string) {
imageRequests(value);
queueMicrotask(() => {
this.complete = true;
this.naturalWidth = value === brokenHumanAvatar ? 0 : 100;
this.dispatchEvent(new Event(this.naturalWidth ? "load" : "error"));
});
}
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
async function render(items: TaskListItem[]) {
await act(async () => root.render(
<TooltipProvider><TaskList items={items} initialColumns={["task", "assignee"]} /></TooltipProvider>,
));
}
const cell = (title: string) => host.querySelector(`[role="rowgroup"][aria-label="${title} task group"] [role="cell"][data-column="assignee"]`)!;
it("shows agent appearance and human profile pictures beside their names", async () => {
await render([agentItem, humanItem]);
expect(cell("Agent task").textContent).toBe("Research Agent");
expect(cell("Agent task").querySelector("img")?.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content");
expect(cell("Human task").textContent).toBe("Alex Smith");
expect(cell("Human task").querySelector("img")?.getAttribute("src")).toBe(humanItem.assigneeUserAvatarUrl);
});
it.each([
"https://example.com/track.png",
"http://example.com/track.png",
"//example.com/track.png",
"/profile/alex.png",
])("uses initials without loading an untrusted human avatar: %s", async (source) => {
await render([{ ...humanItem, assigneeUserAvatarUrl: source }]);
expect(cell("Human task").querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("AS");
expect(cell("Human task").querySelector("img")).toBeNull();
expect(imageRequests).not.toHaveBeenCalled();
});
it("uses initials for missing or failed pictures and leaves unassigned rows without an avatar", async () => {
const unassigned: TaskListItem = {
issue: createIssue({ id: "unassigned-task", title: "Unassigned task", assigneeAgentId: null, assigneeUserId: null }),
};
await render([agentItem, { ...humanItem, assigneeUserAvatarUrl: null }, unassigned]);
expect(cell("Human task").querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("AS");
expect(cell("Unassigned task").textContent).toBe("Unassigned");
expect(cell("Unassigned task").querySelector("img, [data-slot=avatar], [data-slot=agent-avatar]")).toBeNull();
await act(async () => cell("Agent task").querySelector("img")!.dispatchEvent(new Event("error")));
expect(cell("Agent task").querySelector('[data-slot="agent-avatar"]')?.textContent).toBe("RA");
await render([{ ...humanItem, assigneeUserAvatarUrl: brokenHumanAvatar }]);
expect(cell("Human task").querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("AS");
expect(cell("Human task").querySelector("img")).toBeNull();
});
it("removes and restores assignee avatars through the Columns control", async () => {
await render([agentItem, humanItem]);
const columns = host.querySelector<HTMLButtonElement>('button[aria-label="Columns"]')!;
await act(async () => columns.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
const assignee = [...document.querySelectorAll<HTMLElement>('[role="menuitemcheckbox"]')].find(item => item.textContent === "Assignee")!;
expect(assignee).toBeDefined();
await act(async () => assignee.click());
expect(host.querySelector('[role="cell"][data-column="assignee"]')).toBeNull();
expect(host.querySelector("img")).toBeNull();
await act(async () => assignee.click());
expect(cell("Agent task").querySelector("img")?.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content");
expect(cell("Human task").querySelector("img")?.getAttribute("src")).toBe(humanItem.assigneeUserAvatarUrl);
});
});