From 952ecdbb39b8464c9b4bf32ee8fa0727521232c1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:01:02 -0600 Subject: [PATCH] feat(ui): show assignee avatars in task lists (#18607) 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. > - 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 --- DESIGN.md | 9 +- ui/src/components/TaskList.assignees.test.tsx | 122 ++++++++++++++++++ ui/src/components/TaskList.tsx | 18 ++- ui/src/components/TaskListColumns.tsx | 10 +- ui/src/pages/CoordinatorTasks.test.tsx | 13 +- ui/src/pages/CoordinatorTasks.tsx | 13 +- ui/src/pages/DesignSandboxTasks.tsx | 13 +- .../content | Bin 0 -> 1450 bytes .../task-row-design.stories.tsx | 2 +- .../design-proposals/task-list-data.ts | 5 +- .../design-proposals/task-page-data.ts | 2 +- 11 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 ui/src/components/TaskList.assignees.test.tsx create mode 100644 ui/storybook/public/api/assets/33333333-3333-4333-8333-333333333333/content diff --git a/DESIGN.md b/DESIGN.md index 026e1f6b633..cff31c9a04c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -407,9 +407,12 @@ Project, Parent Task, Tags, Worktree, Last updated**. The reusable width tokens and configurable column order. Review the complete composition in **Design proposals → Components → Task list → All columns**. -- Created by is the recorded creator (human or agent); Assignee is the current - assignee. Both show names without avatars. Do not infer either from tree depth - or task status. +- Created by is the recorded creator (human or agent) and shows a name. Assignee + shows the current agent's avatar or human's profile photo beside their name, + with the shared avatar fallback when an image is unavailable. Human photos load + only from Paperclip asset URLs; external profile URLs use initials. The avatar stays + visible while a long name reveals on hover or focus. Do not infer either + identity from tree depth or task status. - Task combines the status icon and title under one header and one Columns-menu choice. Sorting Task uses the title. Status sorting is unavailable for now. - Parent Task refers to the structural parent; it can show its ID and title. diff --git a/ui/src/components/TaskList.assignees.test.tsx b/ui/src/components/TaskList.assignees.test.tsx new file mode 100644 index 00000000000..e418cdca5e9 --- /dev/null +++ b/ui/src/components/TaskList.assignees.test.tsx @@ -0,0 +1,122 @@ +// @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 }) => + {children}, +})); + +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( + , + )); + } + + 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('button[aria-label="Columns"]')!; + await act(async () => columns.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))); + const assignee = [...document.querySelectorAll('[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); + }); +}); diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index b653de661b9..791219d0f37 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -3,6 +3,7 @@ import type { Issue } from "@paperclipai/shared"; import { ChevronDown, ChevronRight, GitBranch } from "lucide-react"; import { buildIssueTree } from "../lib/issue-tree"; import { issueFilterLabel } from "../lib/issue-filters"; +import { paperclipHumanAvatarUrl } from "../lib/issue-chat-human-author"; import { defaultTaskListColumns, defaultTaskListSort, isTaskListColumnSort, taskListOrderToIssueSort, type TaskListColumnId, type TaskListSort, type TaskListOrder } from "../lib/task-list"; import { sortIssues } from "../lib/issue-sort"; import { timeAgo } from "../lib/timeAgo"; @@ -13,6 +14,9 @@ import { TaskRowGroup } from "./TaskRowGroup"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { useTaskRowBudget } from "../hooks/useTaskRowBudget"; +import { AgentAvatar, type AvatarAgent } from "./AgentAvatar"; +import { deriveInitials } from "./Identity"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { useTaskListColumnPreferences } from "../hooks/useTaskListColumnPreferences"; import type { TaskCollectionPreferenceLocation } from "../lib/task-collection-preferences"; @@ -21,6 +25,8 @@ export type TaskListItem = { unread?: boolean; createdBy?: string | null; assignee?: string | null; + assigneeAgent?: AvatarAgent | null; + assigneeUserAvatarUrl?: string | null; }; export type TaskListGrouping = "none" | "status" | "project"; @@ -94,6 +100,8 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask const expanded = !collapsed.has(issue.id); const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null; const branch = issue.currentExecutionWorkspace?.branchName; + const assigneeName = item.assignee ?? (issue.assigneeAgentId ? "Unknown agent" : issue.assigneeUserId ? "User" : "Unassigned"); + const assigneeUserAvatarUrl = paperclipHumanAvatarUrl(item.assigneeUserAvatarUrl); return ( + ) : issue.assigneeUserId ? ( + + ) : null, project: issue.project?.name, parentTask: parent ? {parent.identifier} {parent.title} : undefined, tags: issue.labels?.length ? {issue.labels.map(label => {label.name})} : undefined, diff --git a/ui/src/components/TaskListColumns.tsx b/ui/src/components/TaskListColumns.tsx index d1dd128f528..2815818cf7f 100644 --- a/ui/src/components/TaskListColumns.tsx +++ b/ui/src/components/TaskListColumns.tsx @@ -56,10 +56,11 @@ export function TaskListViewport({ children, visibleColumns = defaultTaskListCol const detailColumns = taskListColumns.filter(column => column.id !== "task"); type DetailColumn = Exclude<(typeof detailColumns)[number]["id"], "taskId">; -/** Values are resolved by the collection; identity columns intentionally accept names only. */ +/** Values are resolved by the collection; names remain separate from avatar presentation. */ export type TaskRowColumnValues = Partial, ReactNode>> & { createdBy?: string | null; assignee?: string | null; + assigneeAvatar?: ReactNode; }; function SortableColumnHeader({ id, label, start, sort, onSortChange, onColumnOrderChange, helpId }: { @@ -184,7 +185,12 @@ export function TaskRowColumnCells({ identifier, values }: { identifier: string; {columns.filter(id => id !== "task").map(id => ( - {id === "taskId" ? identifier : values[id] ?? "—"} + {id === "assignee" && values.assigneeAvatar ? ( + + {values.assigneeAvatar} + {values.assignee ?? "—"} + + ) : {id === "taskId" ? identifier : values[id] ?? "—"}} ))} diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 1ad5885cde5..3a61befe4ec 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -45,8 +45,8 @@ vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, vi })); vi.mock("@/components/TaskList", async importOriginal => ({ ...await importOriginal(), - TaskList: ({ items, emptyState, onSortChange }: { onSortChange?: (sort: TaskListSort) => void; items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) => -
{useLocation().search}{items.length ? items.map(item =>

{item.issue.title}

) : emptyState}
, + TaskList: ({ items, emptyState, onSortChange }: { onSortChange?: (sort: TaskListSort) => void; items: { issue: { id: string; title: string }; assignee?: string | null }[]; emptyState: React.ReactNode }) => +
{useLocation().search}{items.length ? items.map(item =>

{item.issue.title}

) : emptyState}
, })); describe("Coordinator tasks using the proposed table", () => { @@ -152,6 +152,15 @@ describe("Coordinator tasks using the proposed table", () => { expect(mocks.get).not.toHaveBeenCalled(); }); + it.each([ + ["local-board", "Board"], + ["missing-user", "missi"], + ])("keeps the shared human identity label when %s is absent from the directory", async (assigneeUserId, label) => { + mocks.list.mockResolvedValue([createIssue({ id: "human-task", companyId: "company", parentId: null, assigneeAgentId: null, assigneeUserId })]); + await mount("/issues"); + expect(host.querySelector('[data-task-id="human-task"]')?.getAttribute("data-assignee")).toBe(label); + }); + it("opens routine task links, filters before paging, and lets the checkbox change the collection", async () => { const routine = createIssue({ id: "routine-task", companyId: "company", parentId: null, originKind: "routine_execution", title: "Routine report" }); const manual = createIssue({ id: "manual-task", companyId: "company", parentId: null, originKind: "manual", title: "Manual report" }); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 2e38c33b197..151668e54d5 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -24,7 +24,8 @@ import { useIssueRowDetails } from "@/hooks/useIssueRowDetails"; import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; import { usePublishSharedQueryData, useSharedPollingQuery } from "@/hooks/useSharedPolling"; import { collectLiveIssueIds } from "@/lib/liveIssueIds"; -import { buildCompanyUserLabelMap } from "@/lib/company-members"; +import { formatUserLabel } from "@/lib/assignees"; +import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention"; import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb"; import { defaultIssueFilterState, type IssueFilterState } from "@/lib/issue-filters"; @@ -155,8 +156,8 @@ export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } = const sharedLiveRuns = useSharedPollingQuery({ companyId, resourceKey: "live-runs", queryKey: queryKeys.liveRuns(companyId!), enabled: !!companyId, refetchInterval: false, leaderOnly: true }); const liveRuns = useQuery({ queryKey: queryKeys.liveRuns(companyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(companyId!), enabled: sharedLiveRuns.enabled, refetchInterval: sharedLiveRuns.refetchInterval }); usePublishSharedQueryData(sharedLiveRuns, liveRuns.data, liveRuns.dataUpdatedAt); - const users = buildCompanyUserLabelMap(directory.data?.users); - const agentNames = new Map(agents.data?.map(agent => [agent.id, agent.name])); + const users = buildCompanyUserProfileMap(directory.data?.users); + const agentById = new Map(agents.data?.map(agent => [agent.id, agent])); const projectById = new Map(projects.data?.map(project => [project.id, project])); const records = (needsAttention ? attentionTasks.flatMap(task => task.data ?? []) : tasks.data?.pages.flatMap(page => page.items) ?? []) as Issue[]; const unreadIds = new Set(view === "unread" ? records.map(issue => issue.id) : []); @@ -190,8 +191,10 @@ export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } = const items: TaskListItem[] = [...new Map(scopedRecords.map(issue => [issue.id, issue])).values()].map(issue => ({ issue: { ...issue, project: issue.project ?? projectById.get(issue.projectId ?? "") }, unread: unreadIds.has(issue.id) || issue.isUnreadForMe === true, - createdBy: issue.createdByAgentId ? agentNames.get(issue.createdByAgentId) ?? "Unknown agent" : users.get(issue.createdByUserId ?? "") ?? "Board", - assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? null, + createdBy: issue.createdByAgentId ? agentById.get(issue.createdByAgentId)?.name ?? "Unknown agent" : users.get(issue.createdByUserId ?? "")?.label ?? "Board", + assignee: issue.assigneeAgentId ? agentById.get(issue.assigneeAgentId)?.name ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "")?.label ?? formatUserLabel(issue.assigneeUserId), + assigneeAgent: agentById.get(issue.assigneeAgentId ?? ""), + assigneeUserAvatarUrl: users.get(issue.assigneeUserId ?? "")?.image, })); const collection = useTaskListCollection({ items, companyId, userId, query, onQueryChange: setQuery, view: needsAttention ? "all" : view, layout, onLayoutChange: setLayout, diff --git a/ui/src/pages/DesignSandboxTasks.tsx b/ui/src/pages/DesignSandboxTasks.tsx index ca6ae1fc9c2..3c56c479cf4 100644 --- a/ui/src/pages/DesignSandboxTasks.tsx +++ b/ui/src/pages/DesignSandboxTasks.tsx @@ -16,7 +16,8 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useTaskListCollection } from "@/hooks/useTaskListCollection"; -import { buildCompanyUserLabelMap } from "@/lib/company-members"; +import { formatUserLabel } from "@/lib/assignees"; +import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { queryKeys } from "@/lib/queryKeys"; import { taskListViewStorageKey } from "@/lib/task-list-views"; import type { Issue } from "@paperclipai/shared"; @@ -66,14 +67,16 @@ export function DesignSandboxTasks() { }, enabled: !!companyId && !!userId, }); - const users = buildCompanyUserLabelMap(directory.data?.users); - const agentNames = new Map(agents.data?.map(agent => [agent.id, agent.name])); + const users = buildCompanyUserProfileMap(directory.data?.users); + const agentById = new Map(agents.data?.map(agent => [agent.id, agent])); const projectById = new Map(projects.data?.map(project => [project.id, project])); const items: TaskListItem[] = [...new Map((tasks.data?.pages.flat() ?? []).map(issue => [issue.id, issue])).values()].map(issue => ({ issue: { ...issue, project: issue.project ?? projectById.get(issue.projectId ?? "") }, unread: unread.data?.has(issue.id) ?? false, - createdBy: issue.createdByAgentId ? agentNames.get(issue.createdByAgentId) ?? "Unknown agent" : users.get(issue.createdByUserId ?? "") ?? (issue.createdByUserId === "local-board" ? "Board" : "Unknown user"), - assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? (issue.assigneeUserId === "local-board" ? "Board" : null), + createdBy: issue.createdByAgentId ? agentById.get(issue.createdByAgentId)?.name ?? "Unknown agent" : users.get(issue.createdByUserId ?? "")?.label ?? (issue.createdByUserId === "local-board" ? "Board" : "Unknown user"), + assignee: issue.assigneeAgentId ? agentById.get(issue.assigneeAgentId)?.name ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "")?.label ?? formatUserLabel(issue.assigneeUserId), + assigneeAgent: agentById.get(issue.assigneeAgentId ?? ""), + assigneeUserAvatarUrl: users.get(issue.assigneeUserId ?? "")?.image, })); const collection = useTaskListCollection({ items, companyId, userId, viewStorageKey: taskListViewStorageKey("design-integration-sandbox", userId ?? "local-board"), diff --git a/ui/storybook/public/api/assets/33333333-3333-4333-8333-333333333333/content b/ui/storybook/public/api/assets/33333333-3333-4333-8333-333333333333/content new file mode 100644 index 0000000000000000000000000000000000000000..7e63dcc804a36c6dbeb79776c2d29497d2ce2e87 GIT binary patch literal 1450 zcmV;b1y%ZqP);2?jK)5KPOwQ(t_+lYs zgmPJCS=O4IG7prBXDJgyei2_nh`}PWd_zvilM=CsG8yxm^b%olk23MHHsY3Lt)W~t z`b{*W5KPpgheTna^(gGmev`{0Bt9}tb1fcuF1!6E<-!xrV2DsanLOe*8P{Oqn5S@{ zt03kbc*1U10Y{ZogbO1S>5R5n$Mowx1_RE$!Lfel+q4eED@B zk^nNYlD1;Wk#Hc}-xPxjGO5tz%&;{r@cOIAg!d5tG zJUCrJH4JYG8W_y%Usi?F45frX7&^eeunhxtHMOFI0<^<>_M#3(k~~2EsZD@HU*L5h z0y9Ki&qqK29U+hm7-qN$2XOm6&F^b_uKz-SXh?#ofC1;lOIMtEL+q{^8T(dM)a=yy zOi4*lpl*W+1WH489)Jn&>|8W!510){)rEmes;;rswxvY`CPob%XMRns?P~niZM0qB z<_{C9asLMz1dyEuTwcO5Sha1Z8X3ERUIQdBa^;4q#0(84YQO_FV;E~}X;V|vVQu6S zfP}j5UzBLw3bno8Br~C|v1PfpKW&;-kEay?7G;vAGtf79*>{4OaK3*i zy$7(+>xP3>0C#Uw|Ck4>U3GQ2#>`wqmAqNCgzJlbM^|LhU6R_p_ki!@oZ`KPlrUg2 zmdzz8d!?QjLGA4~F{k+CRQKW&AUBJ^zp6YiGB|wIcVbR4Fg%*{_C=lHVHFT2I&`1; z={iG%OVgfjj+>^r7A{NwHZ|irF{k+T_h~41Pov!!w=C=j)Br1Ap9^B$n`ArQ~!0F_UI@%%2Ju(rW}0YA|I@|Tetn*aa+07*qoM6N<$ Ef&r7JBme*a literal 0 HcmV?d00001 diff --git a/ui/storybook/stories/design-proposals/task-row-design.stories.tsx b/ui/storybook/stories/design-proposals/task-row-design.stories.tsx index 7c1b1309c56..cf91f8a5d38 100644 --- a/ui/storybook/stories/design-proposals/task-row-design.stories.tsx +++ b/ui/storybook/stories/design-proposals/task-row-design.stories.tsx @@ -224,7 +224,7 @@ const meta = { exception: "Staged design-system refresh awaiting visual review and app adoption; kept separate from production references.", implementation: "production", boundary: "fixtures", - notes: "Library specimen of refreshed IssueRow, TaskRowGroup, and TaskListColumnHeader. Task combines status and title under one header and visibility choice; Task sorting uses titles, with no status sort. All Columns starts with every column and demonstrates sibling hierarchy, sorting, pointer/keyboard column reordering, pinned column settings, visibility and reset using local fixture state. Last updated is last by default; the flyout follows the current column order. Reset restores that order and shows only Task, Task ID, and Last updated. The Columns flyout follows the Paper reference with compact 32px items, regular labels, and right-aligned checks. Sorted headers retain their background. Table typography uses sm with a 16px line height; every truncated cell uses a local marquee. The entire proposal canvas uses Paper's background token. Status tooltips use shared labels. The composition is under review and does not establish production API, preference persistence, or full accessibility acceptance.", + notes: "Library specimen of refreshed IssueRow, TaskRowGroup, and TaskListColumnHeader. Task combines status and title under one header and visibility choice; Task sorting uses titles, with no status sort. All Columns shows agent avatars, a human profile image and human fallback avatars beside assignee names. Created by remains a name. Unassigned rows have no avatar. Hiding Assignee removes its avatars. All Columns starts with every column and demonstrates sibling hierarchy, sorting, pointer/keyboard column reordering, pinned column settings, visibility and reset using local fixture state. Last updated is last by default; the flyout follows the current column order. Reset restores that order and shows only Task, Task ID, and Last updated. The Columns flyout follows the Paper reference with compact 32px items, regular labels, and right-aligned checks. Sorted headers retain their background. Table typography uses sm with a 16px line height; every truncated cell uses a local marquee. The entire proposal canvas uses Paper's background token. Status tooltips use shared labels. The composition is under review and does not establish production API, preference persistence, or full accessibility acceptance.", }, }, } satisfies Meta; diff --git a/ui/storybook/support/design-proposals/task-list-data.ts b/ui/storybook/support/design-proposals/task-list-data.ts index af5d11fb7ca..c064349d42e 100644 --- a/ui/storybook/support/design-proposals/task-list-data.ts +++ b/ui/storybook/support/design-proposals/task-list-data.ts @@ -59,6 +59,9 @@ export function columnData(issue: Issue, owner: string) { parent: treeTasks.find(candidate => candidate.id === issue.parentId), creator: rowIssue.createdByAgentId ? agent.name : "Scott", assignee: rowIssue.assigneeAgentId ? agent.name : rowIssue.assigneeUserId ? "Design" : null, + assigneeAgent: rowIssue.assigneeAgentId ? agent : null, + // Static Storybook fixture served at the same asset path as an uploaded photo. + assigneeUserAvatarUrl: rowIssue.assigneeUserId && issue.id === healthTask.id ? "/api/assets/33333333-3333-4333-8333-333333333333/content" : null, }; } @@ -79,5 +82,5 @@ export function sortValue(issue: Issue, column: TaskListColumnId) { export const reviewTaskItems: TaskListItem[] = treeTasks.map(issue => { const data = columnData(issue, ownerFor(issue)); - return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, unread: issue === connectorParent || issue === healthTask }; + return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, assigneeAgent: data.assigneeAgent, assigneeUserAvatarUrl: data.assigneeUserAvatarUrl, unread: issue === connectorParent || issue === healthTask }; }); diff --git a/ui/storybook/support/design-proposals/task-page-data.ts b/ui/storybook/support/design-proposals/task-page-data.ts index 9b198f6f683..9e666804752 100644 --- a/ui/storybook/support/design-proposals/task-page-data.ts +++ b/ui/storybook/support/design-proposals/task-page-data.ts @@ -7,7 +7,7 @@ const extraTasks: TaskListItem[] = [ task(460, "Collect feedback on the refreshed task list", "todo"), ].map((issue, index) => { const data = columnData(issue, index === 2 ? "Design" : "Codex"); - return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, unread: index === 0 }; + return { issue: data.issue, createdBy: data.creator, assignee: data.assignee, assigneeAgent: data.assigneeAgent, assigneeUserAvatarUrl: data.assigneeUserAvatarUrl, unread: index === 0 }; }); export const initialItems = [...reviewTaskItems, ...extraTasks]; export const recentTasks = [...initialItems]