From ddae5624e64912b7edb95ff77b51fcfd971b7ad7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 10:55:13 -0500 Subject: [PATCH] Show human assignee identities in issue tables --- ui/src/components/IssueColumns.tsx | 15 +++++-- ui/src/components/IssuesList.test.tsx | 62 +++++++++++++++++++++++++++ ui/src/components/IssuesList.tsx | 41 ++++++++++++++---- ui/src/lib/company-members.test.ts | 26 ++++++++++- ui/src/lib/company-members.ts | 18 ++++++++ ui/src/pages/Inbox.tsx | 25 +++++++++++ 6 files changed, 175 insertions(+), 12 deletions(-) diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index 516a8c4506b..492ba2fba74 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -196,6 +196,8 @@ export function InboxIssueTrailingColumns({ workspaceId, workspaceName, assigneeName, + assigneeUserName, + assigneeUserAvatarUrl, currentUserId, parentIdentifier, parentTitle, @@ -209,6 +211,8 @@ export function InboxIssueTrailingColumns({ workspaceId?: string | null; workspaceName: string | null; assigneeName: string | null; + assigneeUserName?: string | null; + assigneeUserAvatarUrl?: string | null; currentUserId: string | null; parentIdentifier: string | null; parentTitle: string | null; @@ -216,7 +220,7 @@ export function InboxIssueTrailingColumns({ onFilterWorkspace?: (workspaceId: string) => void; }) { const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); - const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; + const userLabel = assigneeUserName ?? formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; return ( - {userLabel} + + ); } diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index e87fa41c8ba..a3bec03d8ff 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -26,6 +26,10 @@ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), })); +const mockAccessApi = vi.hoisted(() => ({ + listMembers: vi.fn(), +})); + const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), })); @@ -50,6 +54,10 @@ vi.mock("../api/auth", () => ({ authApi: mockAuthApi, })); +vi.mock("../api/access", () => ({ + accessApi: mockAccessApi, +})); + vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi, })); @@ -182,11 +190,13 @@ describe("IssuesList", () => { mockIssuesApi.list.mockReset(); mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); + mockAccessApi.listMembers.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); + mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); localStorage.clear(); @@ -314,6 +324,58 @@ describe("IssuesList", () => { }); }); + it("shows human assignee names from company member profiles", async () => { + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "assignee"])); + mockAccessApi.listMembers.mockResolvedValue({ + members: [ + { + id: "member-1", + companyId: "company-1", + principalType: "user", + principalId: "user-2", + status: "active", + membershipRole: "operator", + createdAt: "2026-04-07T00:00:00.000Z", + updatedAt: "2026-04-07T00:00:00.000Z", + user: { + id: "user-2", + name: "Jordan Lee", + email: "jordan@example.com", + image: "https://example.com/jordan.png", + }, + grants: [], + }, + ], + access: {}, + }); + + const assignedIssue = createIssue({ + id: "issue-human", + identifier: "PAP-12", + title: "Human assigned issue", + assigneeUserId: "user-2", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Jordan Lee"); + }); + + act(() => { + root.unmount(); + }); + }); + it("filters the list to a single workspace when a workspace name is clicked", async () => { localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"])); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 9dde9e08807..071e83ff820 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,6 @@ import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "../api/access"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { executionWorkspacesApi } from "../api/execution-workspaces"; @@ -12,6 +13,7 @@ import { shouldBlurPageSearchOnEscape, } from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; +import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; import { groupBy } from "../lib/groupBy"; import { applyIssueFilters, @@ -224,6 +226,11 @@ export function IssuesList({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(selectedCompanyId!), + queryFn: () => accessApi.listMembers(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -312,6 +319,15 @@ export function IssuesList({ return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); + const companyUserLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.members), + [companyMembers?.members], + ); + const companyUserProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.members), + [companyMembers?.members], + ); + const projectById = useMemo(() => { const map = new Map(); for (const project of projects ?? []) { @@ -477,11 +493,11 @@ export function IssuesList({ key === "__unassigned" ? "Unassigned" : key.startsWith("__user:") - ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") + ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId, companyUserLabelMap) ?? "User") : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]); + }, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]); const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = {}; @@ -724,6 +740,14 @@ export function IssuesList({ const isExpanded = !viewState.collapsedParents.includes(issue.id); const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null; + const assigneeUserProfile = issue.assigneeUserId + ? companyUserProfileMap.get(issue.assigneeUserId) ?? null + : null; + const assigneeUserLabel = formatAssigneeUserLabel( + issue.assigneeUserId, + currentUserId, + companyUserLabelMap, + ) ?? assigneeUserProfile?.label ?? null; const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { e.preventDefault(); e.stopPropagation(); @@ -797,6 +821,8 @@ export function IssuesList({ })} onFilterWorkspace={filterToWorkspace} assigneeName={agentName(issue.assigneeAgentId)} + assigneeUserName={assigneeUserLabel} + assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null} currentUserId={currentUserId} parentIdentifier={parentIssue?.identifier ?? null} parentTitle={parentIssue?.title ?? null} @@ -816,12 +842,11 @@ export function IssuesList({ {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( ) : issue.assigneeUserId ? ( - - - - - {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} - + ) : ( diff --git a/ui/src/lib/company-members.test.ts b/ui/src/lib/company-members.test.ts index ada78c74b50..a9d9c281edc 100644 --- a/ui/src/lib/company-members.test.ts +++ b/ui/src/lib/company-members.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { CompanyMember } from "@/api/access"; -import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildMarkdownMentionOptions } from "./company-members"; +import { + buildCompanyUserInlineOptions, + buildCompanyUserLabelMap, + buildCompanyUserProfileMap, + buildMarkdownMentionOptions, +} from "./company-members"; const activeMember = (overrides: Partial): CompanyMember => ({ id: overrides.id ?? "member-1", @@ -28,6 +33,25 @@ describe("company-members helpers", () => { expect(labels.get("local-board")).toBe("Board"); }); + it("builds user profiles with labels and avatars", () => { + const profiles = buildCompanyUserProfileMap([ + activeMember({ + principalId: "user-1", + user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: "https://example.com/taylor.png" }, + }), + activeMember({ id: "member-2", principalId: "local-board", user: null }), + ]); + + expect(profiles.get("user-1")).toEqual({ + label: "Taylor", + image: "https://example.com/taylor.png", + }); + expect(profiles.get("local-board")).toEqual({ + label: "Board", + image: null, + }); + }); + it("builds inline options for active users and excludes requested ids", () => { const options = buildCompanyUserInlineOptions([ activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } }), diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts index b9556e3fb24..928b516a9a9 100644 --- a/ui/src/lib/company-members.ts +++ b/ui/src/lib/company-members.ts @@ -3,6 +3,11 @@ import type { InlineEntityOption } from "@/components/InlineEntitySelector"; import type { MentionOption } from "@/components/MarkdownEditor"; import type { Agent, Project } from "@paperclipai/shared"; +export interface CompanyUserProfile { + label: string; + image: string | null; +} + function fallbackUserLabel(userId: string): string { if (userId === "local-board") return "Board"; return userId.slice(0, 5); @@ -35,6 +40,19 @@ export function buildCompanyUserLabelMap(members: CompanyMember[] | null | undef return labels; } +export function buildCompanyUserProfileMap( + members: CompanyMember[] | null | undefined, +): Map { + const profiles = new Map(); + for (const member of members ?? []) { + profiles.set(member.principalId, { + label: baseMemberLabel(member), + image: member.user?.image ?? null, + }); + } + return profiles; +} + export function buildCompanyUserInlineOptions( members: CompanyMember[] | null | undefined, options?: { excludeUserIds?: Iterable }, diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 389ae2f5f1c..0986a416408 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,8 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; +import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, @@ -829,8 +831,22 @@ export function Inbox() { enabled: !!selectedCompanyId, refetchInterval: 5000, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(selectedCompanyId!), + queryFn: () => accessApi.listMembers(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const currentUserId = session?.user.id ?? session?.session.userId ?? null; + const companyUserLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.members), + [companyMembers?.members], + ); + const companyUserProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.members), + [companyMembers?.members], + ); + const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); const visibleMineIssues = useMemo( @@ -1988,6 +2004,9 @@ export function Inbox() { const isFading = fadingOutIssues.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id); const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null; + const assigneeUserProfile = issue.assigneeUserId + ? companyUserProfileMap.get(issue.assigneeUserId) ?? null + : null; return (