mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add human user mentions and picker options
This commit is contained in:
1 parent
232751f1a9
commit
0ea867f5ce
15 files changed
+404
-76
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>(
|
||||
() =>
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
Reference in new issue
Block a user