From 0ea867f5ce22935673f923da95ac10867636551a Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 10:14:49 -0500 Subject: [PATCH] Add human user mentions and picker options --- packages/shared/src/index.ts | 7 +- packages/shared/src/project-mentions.test.ts | 11 +++ packages/shared/src/project-mentions.ts | 40 ++++++++ .../components/ExecutionParticipantPicker.tsx | 39 +++++++- ui/src/components/IssueProperties.tsx | 60 +++++++++++- ui/src/components/MarkdownBody.test.tsx | 8 +- ui/src/components/MarkdownBody.tsx | 2 + ui/src/components/MarkdownEditor.tsx | 28 +++++- ui/src/components/NewIssueDialog.tsx | 40 +++----- ui/src/components/NewProjectDialog.tsx | 28 +++--- ui/src/lib/assignees.ts | 7 ++ ui/src/lib/company-members.test.ts | 60 ++++++++++++ ui/src/lib/company-members.ts | 95 +++++++++++++++++++ ui/src/lib/mention-chips.ts | 15 ++- ui/src/pages/IssueDetail.tsx | 40 +++----- 15 files changed, 404 insertions(+), 76 deletions(-) create mode 100644 ui/src/lib/company-members.test.ts create mode 100644 ui/src/lib/company-members.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 809768d2ae8..83e2c56c33f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -669,18 +669,23 @@ export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, SKILL_MENTION_SCHEME, + USER_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, + extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, - extractProjectMentionIds, + parseUserMentionHref, type ParsedAgentMention, type ParsedProjectMention, type ParsedSkillMention, + type ParsedUserMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 5a156959a98..709fd27d6ed 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -3,12 +3,15 @@ import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, + buildUserMentionHref, extractAgentMentionIds, extractProjectMentionIds, extractSkillMentionIds, + extractUserMentionIds, parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, + parseUserMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -30,6 +33,14 @@ describe("project-mentions", () => { expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + it("round-trips user mentions", () => { + const href = buildUserMentionHref("user-123"); + expect(parseUserMentionHref(href)).toEqual({ + userId: "user-123", + }); + expect(extractUserMentionIds(`[@Taylor](${href})`)).toEqual(["user-123"]); + }); + it("round-trips skill mentions with slug metadata", () => { const href = buildSkillMentionHref("skill-123", "release-changelog"); expect(parseSkillMentionHref(href)).toEqual({ diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 117fad39474..b2d3ee93169 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const USER_MENTION_SCHEME = "user://"; export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; @@ -8,6 +9,7 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; @@ -22,6 +24,10 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedUserMention { + userId: string; +} + export interface ParsedSkillMention { skillId: string; slug: string | null; @@ -111,6 +117,28 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildUserMentionHref(userId: string): string { + return `${USER_MENTION_SCHEME}${userId.trim()}`; +} + +export function parseUserMentionHref(href: string): ParsedUserMention | null { + if (!href.startsWith(USER_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "user:") return null; + + const userId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!userId) return null; + + return { userId }; +} + export function buildSkillMentionHref(skillId: string, slug?: string | null): string { const trimmedSkillId = skillId.trim(); const normalizedSlug = normalizeSkillSlug(slug ?? null); @@ -165,6 +193,18 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractUserMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(USER_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseUserMentionHref(match[1]); + if (parsed) ids.add(parsed.userId); + } + return [...ids]; +} + export function extractSkillMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx index 7e8942b58d9..b1aa22c7468 100644 --- a/ui/src/components/ExecutionParticipantPicker.tsx +++ b/ui/src/components/ExecutionParticipantPicker.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { Agent, Issue } from "@paperclipai/shared"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "../api/access"; import { formatAssigneeUserLabel } from "../lib/assignees"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; +import { queryKeys } from "../lib/queryKeys"; import { sortAgentsByRecency, getRecentAssigneeIds } from "../lib/recent-assignees"; import { buildExecutionPolicy, @@ -34,14 +38,27 @@ export function ExecutionParticipantPicker({ const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); const values = stageType === "review" ? reviewerValues : approverValues; + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(issue.companyId), + queryFn: () => accessApi.listMembers(issue.companyId), + enabled: !!issue.companyId, + }); const sortedAgents = sortAgentsByRecency( agents.filter((a) => a.status !== "terminated"), getRecentAssigneeIds(), ); + const userLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.members), + [companyMembers?.members], + ); + const otherUserOptions = useMemo( + () => buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId, issue.createdByUserId] }), + [companyMembers?.members, currentUserId, issue.createdByUserId], + ); const userLabel = (userId: string | null | undefined) => - formatAssigneeUserLabel(userId, currentUserId); + formatAssigneeUserLabel(userId, currentUserId, userLabelMap); const creatorUserLabel = userLabel(issue.createdByUserId); const agentName = (id: string) => { @@ -138,6 +155,24 @@ export function ExecutionParticipantPicker({ {creatorUserLabel ?? "Requester"} )} + {otherUserOptions + .filter((option) => { + if (!search.trim()) return true; + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); + }) + .map((option) => ( + + ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ed7a5c31fd9..c4ea7c2318d 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -3,12 +3,14 @@ import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { formatAssigneeUserLabel } from "../lib/assignees"; @@ -189,6 +191,11 @@ export function IssueProperties({ queryFn: () => agentsApi.list(companyId!), enabled: !!companyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(companyId!), + queryFn: () => accessApi.listMembers(companyId!), + enabled: !!companyId, + }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), @@ -259,13 +266,21 @@ export function IssueProperties({ () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), [agents, recentAssigneeIds], ); + const userLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.members), + [companyMembers?.members], + ); + const otherUserOptions = useMemo( + () => buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId, issue.createdByUserId] }), + [companyMembers?.members, currentUserId, issue.createdByUserId], + ); const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; const reviewerValues = stageParticipantValues(issue.executionPolicy, "review"); const approverValues = stageParticipantValues(issue.executionPolicy, "approval"); - const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); + const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { @@ -501,6 +516,31 @@ export function IssueProperties({ {creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"} )} + {otherUserOptions + .filter((option) => { + if (!assigneeSearch.trim()) return true; + const q = assigneeSearch.toLowerCase(); + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q); + }) + .map((option) => { + const userId = option.id.slice("user:".length); + return ( + + ); + })} {sortedAgents .filter((a) => { if (!assigneeSearch.trim()) return true; @@ -573,6 +613,24 @@ export function IssueProperties({ {creatorUserLabel ? creatorUserLabel : "Requester"} )} + {otherUserOptions + .filter((option) => { + if (!search.trim()) return true; + return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase()); + }) + .map((option) => ( + + ))} {sortedAgents .filter((agent) => { if (!search.trim()) return true; diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index f30db99101c..64b11cb9517 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref, buildUserMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; import { queryKeys } from "../lib/queryKeys"; @@ -75,17 +75,19 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); - it("renders agent, project, and skill mentions as chips", () => { + it("renders user, agent, project, and skill mentions as chips", () => { const html = renderToStaticMarkup( - {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} + {`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} , ); + expect(html).toContain('href="/company/settings/access"'); + expect(html).toContain('data-mention-kind="user"'); expect(html).toContain('href="/agents/agent-123"'); expect(html).toContain('data-mention-kind="agent"'); expect(html).toContain("--paperclip-mention-icon-mask"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index c9fdf85988a..6636c318048 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -167,6 +167,8 @@ export function MarkdownBody({ ? `/projects/${parsed.projectId}` : parsed.kind === "skill" ? `/skills/${parsed.skillId}` + : parsed.kind === "user" + ? "/company/settings/access" : `/agents/${parsed.agentId}`; return ( const agentId = mention.agentId ?? mention.id.replace(/^agent:/, ""); map.set(`agent:${agentId}`, mention); } + if (mention.kind === "user" && mention.userId) { + map.set(`user:${mention.userId}`, mention); + } if (mention.kind === "project" && mention.projectId) { map.set(`project:${mention.projectId}`, mention); } @@ -594,6 +604,11 @@ export const MarkdownEditor = forwardRef continue; } + if (parsed.kind === "user") { + applyMentionChipDecoration(link, parsed); + continue; + } + const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); applyMentionChipDecoration(link, { ...parsed, @@ -938,6 +953,8 @@ export const MarkdownEditor = forwardRef className="inline-flex h-2 w-2 rounded-full border border-border/50" style={{ backgroundColor: option.projectColor ?? "#64748b" }} /> + ) : option.kind === "user" ? ( + ) : ( Project )} + {option.kind === "user" && ( + + User + + )} {option.kind === "skill" && ( Skill diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 7798c2348aa..80b0aa8f73d 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -8,8 +8,10 @@ import { issuesApi } from "../api/issues"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; +import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; +import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; @@ -353,6 +355,11 @@ export function NewIssueDialog() { queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(effectiveCompanyId!), + queryFn: () => accessApi.listMembers(effectiveCompanyId!), + enabled: Boolean(effectiveCompanyId) && newIssueOpen, + }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -379,30 +386,12 @@ export function NewIssueDialog() { assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); const mentionOptions = useMemo(() => { - const options: MentionOption[] = []; - const activeAgents = [...(agents ?? [])] - .filter((agent) => agent.status !== "terminated") - .sort((a, b) => a.name.localeCompare(b.name)); - for (const agent of activeAgents) { - options.push({ - id: `agent:${agent.id}`, - name: agent.name, - kind: "agent", - agentId: agent.id, - agentIcon: agent.icon, - }); - } - for (const project of orderedProjects) { - options.push({ - id: `project:${project.id}`, - name: project.name, - kind: "project", - projectId: project.id, - projectColor: project.color, - }); - } - return options; - }, [agents, orderedProjects]); + return buildMarkdownMentionOptions({ + agents, + projects: orderedProjects, + members: companyMembers?.members, + }); + }, [agents, companyMembers?.members, orderedProjects]); const { data: assigneeAdapterModels } = useQuery({ queryKey: @@ -868,6 +857,7 @@ export function NewIssueDialog() { const assigneeOptions = useMemo( () => [ ...currentUserAssigneeOption(currentUserId), + ...buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId] }), ...sortAgentsByRecency( (agents ?? []).filter((agent) => agent.status !== "terminated"), recentAssigneeIds, @@ -877,7 +867,7 @@ export function NewIssueDialog() { searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, })), ], - [agents, currentUserId, recentAssigneeIds], + [agents, companyMembers?.members, currentUserId, recentAssigneeIds], ); const projectOptions = useMemo( () => diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 367384ac8ac..01024f922c9 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -2,10 +2,12 @@ import { useMemo, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { accessApi } from "../api/access"; import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; import { goalsApi } from "../api/goals"; import { assetsApi } from "../api/assets"; +import { buildMarkdownMentionOptions } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { Dialog, @@ -75,22 +77,18 @@ export function NewProjectDialog() { enabled: !!selectedCompanyId && newProjectOpen, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(selectedCompanyId!), + queryFn: () => accessApi.listMembers(selectedCompanyId!), + enabled: !!selectedCompanyId && newProjectOpen, + }); + const mentionOptions = useMemo(() => { - const options: MentionOption[] = []; - const activeAgents = [...(agents ?? [])] - .filter((agent) => agent.status !== "terminated") - .sort((a, b) => a.name.localeCompare(b.name)); - for (const agent of activeAgents) { - options.push({ - id: `agent:${agent.id}`, - name: agent.name, - kind: "agent", - agentId: agent.id, - agentIcon: agent.icon, - }); - } - return options; - }, [agents]); + return buildMarkdownMentionOptions({ + agents, + members: companyMembers?.members, + }); + }, [agents, companyMembers?.members]); const createProject = useMutation({ mutationFn: (data: Record) => diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 87a278ce575..edc4d5456f6 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -74,9 +74,16 @@ export function currentUserAssigneeOption(currentUserId: string | null | undefin export function formatAssigneeUserLabel( userId: string | null | undefined, currentUserId: string | null | undefined, + userLabels?: ReadonlyMap | Record | null, ): string | null { if (!userId) return null; if (currentUserId && userId === currentUserId) return "You"; + if (userLabels) { + const label = userLabels instanceof Map + ? userLabels.get(userId) + : (userLabels as Record)[userId]; + if (typeof label === "string" && label.trim()) return label; + } if (userId === "local-board") return "Board"; return userId.slice(0, 5); } diff --git a/ui/src/lib/company-members.test.ts b/ui/src/lib/company-members.test.ts new file mode 100644 index 00000000000..ada78c74b50 --- /dev/null +++ b/ui/src/lib/company-members.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { CompanyMember } from "@/api/access"; +import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildMarkdownMentionOptions } from "./company-members"; + +const activeMember = (overrides: Partial): CompanyMember => ({ + id: overrides.id ?? "member-1", + companyId: overrides.companyId ?? "company-1", + principalType: "user", + principalId: overrides.principalId ?? "user-1", + status: overrides.status ?? "active", + membershipRole: overrides.membershipRole ?? "operator", + createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z", + user: overrides.user === undefined + ? { id: overrides.principalId ?? "user-1", name: "Taylor", email: "taylor@example.com", image: null } + : overrides.user, + grants: overrides.grants ?? [], +}); + +describe("company-members helpers", () => { + it("builds labels from company member profiles", () => { + const labels = buildCompanyUserLabelMap([ + activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } }), + activeMember({ id: "member-2", principalId: "local-board", user: null }), + ]); + + expect(labels.get("user-1")).toBe("Taylor"); + expect(labels.get("local-board")).toBe("Board"); + }); + + 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 } }), + activeMember({ id: "member-2", principalId: "user-2", user: { id: "user-2", name: "Jordan", email: "jordan@example.com", image: null } }), + activeMember({ id: "member-3", principalId: "user-3", status: "suspended" }), + ], { excludeUserIds: ["user-1"] }); + + expect(options).toEqual([ + { + id: "user:user-2", + label: "Jordan", + searchText: "Jordan jordan@example.com user-2", + }, + ]); + }); + + it("includes human users in markdown mention options", () => { + const options = buildMarkdownMentionOptions({ + members: [activeMember({ principalId: "user-1", user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null } })], + agents: [{ id: "agent-1", name: "CodexCoder", status: "active", icon: "code" }], + projects: [{ id: "project-1", name: "Paperclip App", color: "#336699" }], + }); + + expect(options).toEqual([ + { id: "user:user-1", name: "Taylor", kind: "user", userId: "user-1" }, + { id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" }, + { id: "project:project-1", name: "Paperclip App", kind: "project", projectId: "project-1", projectColor: "#336699" }, + ]); + }); +}); diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts new file mode 100644 index 00000000000..b9556e3fb24 --- /dev/null +++ b/ui/src/lib/company-members.ts @@ -0,0 +1,95 @@ +import type { CompanyMember } from "@/api/access"; +import type { InlineEntityOption } from "@/components/InlineEntitySelector"; +import type { MentionOption } from "@/components/MarkdownEditor"; +import type { Agent, Project } from "@paperclipai/shared"; + +function fallbackUserLabel(userId: string): string { + if (userId === "local-board") return "Board"; + return userId.slice(0, 5); +} + +function baseMemberLabel(member: Pick): string { + const name = member.user?.name?.trim(); + if (name) return name; + const email = member.user?.email?.trim(); + if (email) return email; + return fallbackUserLabel(member.principalId); +} + +function activeUniqueMembers(members: CompanyMember[] | null | undefined) { + const byId = new Map(); + for (const member of members ?? []) { + if (member.status !== "active") continue; + if (!byId.has(member.principalId)) { + byId.set(member.principalId, member); + } + } + return [...byId.values()].sort((left, right) => baseMemberLabel(left).localeCompare(baseMemberLabel(right))); +} + +export function buildCompanyUserLabelMap(members: CompanyMember[] | null | undefined): Map { + const labels = new Map(); + for (const member of members ?? []) { + labels.set(member.principalId, baseMemberLabel(member)); + } + return labels; +} + +export function buildCompanyUserInlineOptions( + members: CompanyMember[] | null | undefined, + options?: { excludeUserIds?: Iterable }, +): InlineEntityOption[] { + const exclude = new Set( + [...(options?.excludeUserIds ?? [])].filter((value): value is string => Boolean(value)), + ); + + return activeUniqueMembers(members) + .filter((member) => !exclude.has(member.principalId)) + .map((member) => ({ + id: `user:${member.principalId}`, + label: baseMemberLabel(member), + searchText: [member.user?.name, member.user?.email, member.principalId].filter(Boolean).join(" "), + })); +} + +export function buildCompanyUserMentionOptions( + members: CompanyMember[] | null | undefined, +): MentionOption[] { + return activeUniqueMembers(members).map((member) => ({ + id: `user:${member.principalId}`, + name: baseMemberLabel(member), + kind: "user", + userId: member.principalId, + })); +} + +export function buildMarkdownMentionOptions(args: { + agents?: Array> | null | undefined; + projects?: Array> | null | undefined; + members?: CompanyMember[] | null | undefined; +}): MentionOption[] { + const options: MentionOption[] = [ + ...buildCompanyUserMentionOptions(args.members), + ...[...(args.agents ?? [])] + .filter((agent) => agent.status !== "terminated") + .sort((left, right) => left.name.localeCompare(right.name)) + .map((agent) => ({ + id: `agent:${agent.id}`, + name: agent.name, + kind: "agent" as const, + agentId: agent.id, + agentIcon: agent.icon, + })), + ...[...(args.projects ?? [])] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((project) => ({ + id: `project:${project.id}`, + name: project.name, + kind: "project" as const, + projectId: project.id, + projectColor: project.color, + })), + ]; + + return options; +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index 7fecbb8922b..b641224e475 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared"; +import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref, parseUserMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; @@ -14,6 +14,10 @@ export type ParsedMentionChip = projectId: string; color: string | null; } + | { + kind: "user"; + userId: string; + } | { kind: "skill"; skillId: string; @@ -41,6 +45,14 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null { }; } + const user = parseUserMentionHref(href); + if (user) { + return { + kind: "user", + userId: user.userId, + }; + } + const skill = parseSkillMentionHref(href); if (skill) { return { @@ -100,6 +112,7 @@ export function clearMentionChipDecoration(element: HTMLElement) { "paperclip-mention-chip", "paperclip-mention-chip--agent", "paperclip-mention-chip--project", + "paperclip-mention-chip--user", "paperclip-mention-chip--skill", "paperclip-project-mention-chip", ); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fb3159799fb..90c976d90fa 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -7,6 +7,7 @@ import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; +import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; @@ -16,6 +17,7 @@ import { usePanel } from "../context/PanelContext"; import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; +import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { @@ -531,6 +533,11 @@ export function IssueDetail() { queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyMembers(selectedCompanyId!), + queryFn: () => accessApi.listMembers(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, @@ -601,30 +608,12 @@ export function IssueDetail() { }); const mentionOptions = useMemo(() => { - const options: MentionOption[] = []; - const activeAgents = [...(agents ?? [])] - .filter((agent) => agent.status !== "terminated") - .sort((a, b) => a.name.localeCompare(b.name)); - for (const agent of activeAgents) { - options.push({ - id: `agent:${agent.id}`, - name: agent.name, - kind: "agent", - agentId: agent.id, - agentIcon: agent.icon, - }); - } - for (const project of orderedProjects) { - options.push({ - id: `project:${project.id}`, - name: project.name, - kind: "project", - projectId: project.id, - projectColor: project.color, - }); - } - return options; - }, [agents, orderedProjects]); + return buildMarkdownMentionOptions({ + agents, + projects: orderedProjects, + members: companyMembers?.members, + }); + }, [agents, companyMembers?.members, orderedProjects]); const resolvedProject = useMemo( () => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null), @@ -676,6 +665,7 @@ export function IssueDetail() { const commentReassignOptions = useMemo(() => { const options: Array<{ id: string; label: string; searchText?: string }> = []; + options.push(...buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId] })); const activeAgents = [...(agents ?? [])] .filter((agent) => agent.status !== "terminated") .sort((a, b) => a.name.localeCompare(b.name)); @@ -686,7 +676,7 @@ export function IssueDetail() { options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; - }, [agents, currentUserId]); + }, [agents, companyMembers?.members, currentUserId]); const actualAssigneeValue = useMemo( () => assigneeValueFromSelection(issue ?? {}),