Add human user mentions and picker options

This commit is contained in:
Dotta committed 2026-04-13 10:14:49 -05:00
1 parent 232751f1a9
commit 0ea867f5ce
15 files changed
+404 -76

No files matched your search

+6 -1
View File
@@ -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 {
@@ -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({
+40
View File
@@ -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<string>();
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<string>();
@@ -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"}
</button>
)}
{otherUserOptions
.filter((option) => {
if (!search.trim()) return true;
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
})
.map((option) => (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(option.id) && "bg-accent",
)}
onClick={() => toggle(option.id)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
))}
{sortedAgents
.filter((agent) => {
if (!search.trim()) return true;
+59 -1
View File
@@ -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"}
</button>
)}
{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 (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === userId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
);
})}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
@@ -573,6 +613,24 @@ export function IssueProperties({
{creatorUserLabel ? creatorUserLabel : "Requester"}
</button>
)}
{otherUserOptions
.filter((option) => {
if (!search.trim()) return true;
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(search.toLowerCase());
})
.map((option) => (
<button
key={`${stageType}:${option.id}`}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
values.includes(option.id) && "bg-accent",
)}
onClick={() => toggleExecutionParticipant(stageType, option.id)}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
))}
{sortedAgents
.filter((agent) => {
if (!search.trim()) return true;
+5 -3
View File
@@ -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(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>
{`[@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")})`}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
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");
+2
View File
@@ -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 (
<a
+25 -3
View File
@@ -28,8 +28,8 @@ import {
thematicBreakPlugin,
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { Boxes } from "lucide-react";
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared";
import { Boxes, User } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@@ -45,11 +45,12 @@ import { useEditorAutocomplete, type SkillCommandOption } from "../context/Edito
export interface MentionOption {
id: string;
name: string;
kind?: "agent" | "project";
kind?: "agent" | "project" | "user";
agentId?: string;
agentIcon?: string | null;
projectId?: string;
projectColor?: string | null;
userId?: string;
}
/* ---- Editor props ---- */
@@ -285,6 +286,9 @@ function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
}
if (option.kind === "user" && option.userId) {
return `[@${option.name}](${buildUserMentionHref(option.userId)}) `;
}
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
}
@@ -331,6 +335,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
if (option.kind === "project" && option.projectId) {
return parsed.kind === "project" && parsed.projectId === option.projectId;
}
if (option.kind === "user" && option.userId) {
return parsed.kind === "user" && parsed.userId === option.userId;
}
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return parsed.kind === "agent" && parsed.agentId === agentId;
@@ -454,6 +461,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
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<MarkdownEditorRef, MarkdownEditorProps>
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<MarkdownEditorRef, MarkdownEditorProps>
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
/>
) : option.kind === "user" ? (
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<AgentIcon
icon={option.agentIcon}
@@ -950,6 +967,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
Project
</span>
)}
{option.kind === "user" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
User
</span>
)}
{option.kind === "skill" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Skill
+15 -25
View File
@@ -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<MentionOption[]>(() => {
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<InlineEntityOption[]>(
() => [
...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<InlineEntityOption[]>(
() =>
+13 -15
View File
@@ -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<MentionOption[]>(() => {
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<string, unknown>) =>
+7
View File
@@ -74,9 +74,16 @@ export function currentUserAssigneeOption(currentUserId: string | null | undefin
export function formatAssigneeUserLabel(
userId: string | null | undefined,
currentUserId: string | null | undefined,
userLabels?: ReadonlyMap<string, string> | Record<string, string> | 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<string, string>)[userId];
if (typeof label === "string" && label.trim()) return label;
}
if (userId === "local-board") return "Board";
return userId.slice(0, 5);
}
+60
View File
@@ -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>): 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" },
]);
});
});
+95
View File
@@ -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<CompanyMember, "principalId" | "user">): 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<string, CompanyMember>();
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<string, string> {
const labels = new Map<string, string>();
for (const member of members ?? []) {
labels.set(member.principalId, baseMemberLabel(member));
}
return labels;
}
export function buildCompanyUserInlineOptions(
members: CompanyMember[] | null | undefined,
options?: { excludeUserIds?: Iterable<string | null | undefined> },
): 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<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
projects?: Array<Pick<Project, "id" | "name" | "color">> | 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;
}
+14 -1
View File
@@ -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",
);
+15 -25
View File
@@ -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<MentionOption[]>(() => {
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 ?? {}),