mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Show human assignee identities in issue tables
This commit is contained in:
1 parent
0ea867f5ce
commit
ddae5624e6
6 files changed
+175
-12
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user