Show human assignee identities in issue tables

This commit is contained in:
Dotta committed 2026-04-13 10:55:13 -05:00
1 parent 0ea867f5ce
commit ddae5624e6
6 files changed
+175 -12

No files matched your search

+12 -3
View File
@@ -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 (
<span
@@ -243,8 +247,13 @@ export function InboxIssueTrailingColumns({
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={userLabel}
avatarUrl={assigneeUserAvatarUrl}
size="sm"
className="min-w-0"
/>
</span>
);
}
+62
View File
@@ -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(
<IssuesList
issues={[assignedIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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 });
+33 -8
View File
@@ -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<string, { name: string; color: string | null }>();
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<string, string> = {};
@@ -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) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
<Identity
name={assigneeUserLabel ?? "User"}
avatarUrl={assigneeUserProfile?.image ?? null}
size="sm"
/>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
+25 -1
View File
@@ -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>): 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 } }),
+18
View File
@@ -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<string, CompanyUserProfile> {
const profiles = new Map<string, CompanyUserProfile>();
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<string | null | undefined> },
+25
View File
@@ -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 (
<IssueRow
key={`issue:${issue.id}`}
@@ -2064,6 +2083,12 @@ export function Inbox() {
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeUserName={
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
?? assigneeUserProfile?.label
?? null
}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
currentUserId={currentUserId}
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}