diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 03e74aca..4dbc7022 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -228,6 +228,13 @@ export { export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js"; +export { + PROJECT_MENTION_SCHEME, + buildProjectMentionHref, + parseProjectMentionHref, + extractProjectMentionIds, + type ParsedProjectMention, +} from "./project-mentions.js"; export { paperclipConfigSchema, diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts new file mode 100644 index 00000000..2c167517 --- /dev/null +++ b/packages/shared/src/project-mentions.ts @@ -0,0 +1,78 @@ +export const PROJECT_MENTION_SCHEME = "project://"; + +const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; +const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; +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; + +export interface ParsedProjectMention { + projectId: string; + color: string | null; +} + +function normalizeHexColor(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim(); + if (!trimmed) return null; + + if (HEX_COLOR_WITH_HASH_RE.test(trimmed)) { + return trimmed.toLowerCase(); + } + if (HEX_COLOR_RE.test(trimmed)) { + return `#${trimmed.toLowerCase()}`; + } + if (HEX_COLOR_SHORT_WITH_HASH_RE.test(trimmed)) { + const raw = trimmed.slice(1).toLowerCase(); + return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`; + } + if (HEX_COLOR_SHORT_RE.test(trimmed)) { + const raw = trimmed.toLowerCase(); + return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`; + } + return null; +} + +export function buildProjectMentionHref(projectId: string, color?: string | null): string { + const trimmedProjectId = projectId.trim(); + const normalizedColor = normalizeHexColor(color ?? null); + if (!normalizedColor) { + return `${PROJECT_MENTION_SCHEME}${trimmedProjectId}`; + } + return `${PROJECT_MENTION_SCHEME}${trimmedProjectId}?c=${encodeURIComponent(normalizedColor.slice(1))}`; +} + +export function parseProjectMentionHref(href: string): ParsedProjectMention | null { + if (!href.startsWith(PROJECT_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "project:") return null; + + const projectId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!projectId) return null; + + const color = normalizeHexColor(url.searchParams.get("c") ?? url.searchParams.get("color")); + + return { + projectId, + color, + }; +} + +export function extractProjectMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(PROJECT_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseProjectMentionHref(match[1]); + if (parsed) ids.add(parsed.projectId); + } + return [...ids]; +} diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index e0c92c40..f5adeb95 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,5 +1,6 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; -import type { ProjectWorkspace } from "./project.js"; +import type { Goal } from "./goal.js"; +import type { Project, ProjectWorkspace } from "./project.js"; export interface IssueAncestorProject { id: string; @@ -78,6 +79,9 @@ export interface Issue { hiddenAt: Date | null; labelIds?: string[]; labels?: IssueLabel[]; + project?: Project | null; + goal?: Goal | null; + mentionedProjects?: Project[]; createdAt: Date; updatedAt: Date; } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a8b93ff5..44fc96c0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -270,12 +270,16 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } assertCompanyAccess(req, issue.companyId); - const [ancestors, project, goal] = await Promise.all([ + const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([ svc.getAncestors(issue.id), issue.projectId ? projectsSvc.getById(issue.projectId) : null, issue.goalId ? goalsSvc.getById(issue.goalId) : null, + svc.findMentionedProjectIds(issue.id), ]); - res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null }); + const mentionedProjects = mentionedProjectIds.length > 0 + ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) + : []; + res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects }); }); router.get("/issues/:id/approvals", async (req, res) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index eb8d57d1..3952cc82 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -15,6 +15,7 @@ import { projectWorkspaces, projects, } from "@paperclip/db"; +import { extractProjectMentionIds } from "@paperclip/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -907,6 +908,48 @@ export function issueService(db: Db) { return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id); }, + findMentionedProjectIds: async (issueId: string) => { + const issue = await db + .select({ + companyId: issues.companyId, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) return []; + + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + const mentionedIds = new Set(); + for (const source of [ + issue.title, + issue.description ?? "", + ...comments.map((comment) => comment.body), + ]) { + for (const projectId of extractProjectMentionIds(source)) { + mentionedIds.add(projectId); + } + } + if (mentionedIds.size === 0) return []; + + const rows = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.companyId, issue.companyId), + inArray(projects.id, [...mentionedIds]), + ), + ); + const valid = new Set(rows.map((row) => row.id)); + return [...mentionedIds].filter((projectId) => valid.has(projectId)); + }, + getAncestors: async (issueId: string) => { const raw: Array<{ id: string; identifier: string | null; title: string; description: string | null; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index f2ac0ad8..108b39fb 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -217,6 +217,19 @@ export function projectService(db: Db) { return attachWorkspaces(db, withGoals); }, + listByIds: async (companyId: string, ids: string[]): Promise => { + const dedupedIds = [...new Set(ids)]; + if (dedupedIds.length === 0) return []; + const rows = await db + .select() + .from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds))); + const withGoals = await attachGoals(db, rows); + const withWorkspaces = await attachWorkspaces(db, withGoals); + const byId = new Map(withWorkspaces.map((project) => [project.id, project])); + return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project)); + }, + getById: async (id: string): Promise => { const row = await db .select() diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 5988bf91..bf2ef6bb 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -45,6 +45,7 @@ interface CommentThreadProps { liveRunSlot?: React.ReactNode; enableReassign?: boolean; reassignOptions?: ReassignOption[]; + mentions?: MentionOption[]; } const CLOSED_STATUSES = new Set(["done", "cancelled"]); @@ -110,6 +111,7 @@ export function CommentThread({ liveRunSlot, enableReassign = false, reassignOptions = [], + mentions: providedMentions, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -145,6 +147,7 @@ export function CommentThread({ // Build mention options from agent map (exclude terminated agents) const mentions = useMemo(() => { + if (providedMentions) return providedMentions; if (!agentMap) return []; return Array.from(agentMap.values()) .filter((a) => a.status !== "terminated") @@ -152,7 +155,7 @@ export function CommentThread({ id: a.id, name: a.name, })); - }, [agentMap]); + }, [agentMap, providedMentions]); useEffect(() => { if (!draftKey) return; diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 7196bc8a..8cf5515c 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { MarkdownBody } from "./MarkdownBody"; -import { MarkdownEditor } from "./MarkdownEditor"; +import { MarkdownEditor, type MentionOption } from "./MarkdownEditor"; interface InlineEditorProps { value: string; @@ -12,6 +12,7 @@ interface InlineEditorProps { placeholder?: string; multiline?: boolean; imageUploadHandler?: (file: File) => Promise; + mentions?: MentionOption[]; } /** Shared padding so display and edit modes occupy the exact same box. */ @@ -25,6 +26,7 @@ export function InlineEditor({ placeholder = "Click to edit...", multiline = false, imageUploadHandler, + mentions, }: InlineEditorProps) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -81,6 +83,7 @@ export function InlineEditor({ placeholder={placeholder} contentClassName={className} imageUploadHandler={imageUploadHandler} + mentions={mentions} onSubmit={commit} />
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 3b4addbc..2a2ce115 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,5 +1,7 @@ +import type { CSSProperties } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { parseProjectMentionHref } from "@paperclip/shared"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; @@ -8,6 +10,29 @@ interface MarkdownBodyProps { className?: string; } +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const match = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) return null; + const value = match[1]; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function mentionChipStyle(color: string | null): CSSProperties | undefined { + if (!color) return undefined; + const rgb = hexToRgb(color); + if (!rgb) return undefined; + const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + return { + borderColor: color, + backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, + color: luminance > 0.55 ? "#111827" : "#f8fafc", + }; +} + export function MarkdownBody({ children, className }: MarkdownBodyProps) { const { theme } = useTheme(); return ( @@ -18,7 +43,33 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) { className, )} > - {children} + { + const parsed = href ? parseProjectMentionHref(href) : null; + if (parsed) { + const label = linkChildren; + return ( + + {label} + + ); + } + return ( + + {linkChildren} + + ); + }, + }} + > + {children} +
); } diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index b3e06bb6..94af2527 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -6,6 +6,7 @@ import { useMemo, useRef, useState, + type CSSProperties, type DragEvent, } from "react"; import { @@ -24,6 +25,7 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; +import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclip/shared"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -31,6 +33,9 @@ import { cn } from "../lib/utils"; export interface MentionOption { id: string; name: string; + kind?: "agent" | "project"; + projectId?: string; + projectColor?: string | null; } /* ---- Editor props ---- */ @@ -44,7 +49,7 @@ interface MarkdownEditorProps { onBlur?: () => void; imageUploadHandler?: (file: File) => Promise; bordered?: boolean; - /** List of mentionable users/agents. Enables @-mention autocomplete. */ + /** List of mentionable entities. Enables @-mention autocomplete. */ mentions?: MentionOption[]; /** Called on Cmd/Ctrl+Enter */ onSubmit?: () => void; @@ -131,15 +136,47 @@ function detectMention(container: HTMLElement): MentionState | null { }; } -/** Replace `@` in the markdown string with `@ `. */ +function mentionMarkdown(option: MentionOption): string { + if (option.kind === "project" && option.projectId) { + return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; + } + return `@${option.name} `; +} + +/** Replace `@` in the markdown string with the selected mention token. */ function applyMention(markdown: string, query: string, option: MentionOption): string { const search = `@${query}`; - const replacement = `@${option.name} `; + const replacement = mentionMarkdown(option); const idx = markdown.lastIndexOf(search); if (idx === -1) return markdown; return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); } +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const trimmed = hex.trim(); + const match = /^#([0-9a-f]{6})$/i.exec(trimmed); + if (!match) return null; + const value = match[1]; + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +} + +function mentionChipStyle(color: string | null): CSSProperties | undefined { + if (!color) return undefined; + const rgb = hexToRgb(color); + if (!rgb) return undefined; + const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + const textColor = luminance > 0.55 ? "#111827" : "#f8fafc"; + return { + borderColor: color, + backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`, + color: textColor, + }; +} + /* ---- Component ---- */ export const MarkdownEditor = forwardRef(function MarkdownEditor({ @@ -166,6 +203,15 @@ export const MarkdownEditor = forwardRef const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const mentionActive = mentionState !== null && mentions && mentions.length > 0; + const projectColorById = useMemo(() => { + const map = new Map(); + for (const mention of mentions ?? []) { + if (mention.kind === "project" && mention.projectId) { + map.set(mention.projectId, mention.projectColor ?? null); + } + } + return map; + }, [mentions]); const filteredMentions = useMemo(() => { if (!mentionState || !mentions) return []; @@ -218,6 +264,38 @@ export const MarkdownEditor = forwardRef } }, [value]); + const decorateProjectMentions = useCallback(() => { + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!editable) return; + const links = editable.querySelectorAll("a"); + for (const node of links) { + const link = node as HTMLAnchorElement; + const parsed = parseProjectMentionHref(link.getAttribute("href") ?? ""); + if (!parsed) { + if (link.dataset.projectMention === "true") { + link.dataset.projectMention = "false"; + link.classList.remove("paperclip-project-mention-chip"); + link.removeAttribute("contenteditable"); + link.style.removeProperty("border-color"); + link.style.removeProperty("background-color"); + link.style.removeProperty("color"); + } + continue; + } + + const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null; + link.dataset.projectMention = "true"; + link.classList.add("paperclip-project-mention-chip"); + link.setAttribute("contenteditable", "false"); + const style = mentionChipStyle(color); + if (style) { + link.style.borderColor = style.borderColor ?? ""; + link.style.backgroundColor = style.backgroundColor ?? ""; + link.style.color = style.color ?? ""; + } + } + }, [projectColorById]); + // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { if (!mentions || mentions.length === 0 || !containerRef.current) { @@ -251,6 +329,21 @@ export const MarkdownEditor = forwardRef }; }, [checkMention, mentions]); + useEffect(() => { + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!editable) return; + decorateProjectMentions(); + const observer = new MutationObserver(() => { + decorateProjectMentions(); + }); + observer.observe(editable, { + subtree: true, + childList: true, + characterData: true, + }); + return () => observer.disconnect(); + }, [decorateProjectMentions, value]); + const selectMention = useCallback( (option: MentionOption) => { // Read from ref to avoid stale-closure issues (selectionchange can @@ -258,7 +351,7 @@ export const MarkdownEditor = forwardRef const state = mentionStateRef.current; if (!state) return; - const replacement = `@${option.name} `; + const replacement = mentionMarkdown(option); // Replace @query directly via DOM selection so the cursor naturally // lands after the inserted text. Lexical picks up the change through @@ -326,10 +419,14 @@ export const MarkdownEditor = forwardRef }); } + requestAnimationFrame(() => { + decorateProjectMentions(); + }); + mentionStateRef.current = null; setMentionState(null); }, - [onChange], + [decorateProjectMentions, onChange], ); function hasFilePayload(evt: DragEvent) { @@ -452,8 +549,20 @@ export const MarkdownEditor = forwardRef }} onMouseEnter={() => setMentionIndex(i)} > - @ + {option.kind === "project" && option.projectId ? ( + + ) : ( + @ + )} {option.name} + {option.kind === "project" && option.projectId && ( + + Project + + )} ))} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 76c03d65..4f26fa29 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -35,7 +35,7 @@ import { } from "lucide-react"; import { cn } from "../lib/utils"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; -import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; +import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; @@ -200,6 +200,30 @@ export function NewIssueDialog() { const supportsAssigneeOverrides = Boolean( 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", + }); + } + const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + for (const project of sortedProjects) { + options.push({ + id: `project:${project.id}`, + name: project.name, + kind: "project", + projectId: project.id, + projectColor: project.color, + }); + } + return options; + }, [agents, projects]); const { data: assigneeAdapterModels } = useQuery({ queryKey: ["adapter-models", assigneeAdapterType], @@ -744,6 +768,7 @@ export function NewIssueDialog() { onChange={setDescription} placeholder="Add description..." bordered={false} + mentions={mentionOptions} contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); diff --git a/ui/src/index.css b/ui/src/index.css index c8e33eac..a9a8afca 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -295,6 +295,22 @@ margin-top: 0.4rem; } +.paperclip-mdxeditor-content a.paperclip-project-mention-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin: 0 0.1rem; + padding: 0.05rem 0.45rem; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.75rem; + line-height: 1.3; + text-decoration: none; + vertical-align: baseline; + white-space: nowrap; + user-select: none; +} + .paperclip-mdxeditor-content ul, .paperclip-mdxeditor-content ol { margin: 0.35rem 0; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index c0bf7bbd..2175df93 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -17,6 +17,7 @@ import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; +import type { MentionOption } from "../components/MarkdownEditor"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusBadge } from "../components/StatusBadge"; @@ -234,6 +235,31 @@ export function IssueDetail() { return map; }, [agents]); + 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", + }); + } + const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name)); + for (const project of sortedProjects) { + options.push({ + id: `project:${project.id}`, + name: project.name, + kind: "project", + projectId: project.id, + projectColor: project.color, + }); + } + return options; + }, [agents, projects]); + const childIssues = useMemo(() => { if (!allIssues || !issue) return []; return allIssues @@ -609,6 +635,7 @@ export function IssueDetail() { className="text-sm text-muted-foreground" placeholder="Add a description..." multiline + mentions={mentionOptions} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; @@ -715,6 +742,7 @@ export function IssueDetail() { draftKey={`paperclip:issue-comment-draft:${issue.id}`} enableReassign={canReassignFromComment} reassignOptions={commentReassignOptions} + mentions={mentionOptions} onAdd={async (body, reopen, reassignment) => { if (reassignment) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });