paperclip/ui/src/lib/mention-chips.ts
Dotta ab9051b595
Add first-class issue references (#4214)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Operators and agents coordinate through company-scoped issues,
comments, documents, and task relationships.
> - Issue text can mention other tickets, but those references were
previously plain markdown/text without durable relationship data.
> - That made it harder to understand related work, surface backlinks,
and keep cross-ticket context visible in the board.
> - This pull request adds first-class issue reference extraction,
storage, API responses, and UI surfaces.
> - The benefit is that issue references become queryable, navigable,
and visible without relying on ad hoc text scanning.

## What Changed

- Added shared issue-reference parsing utilities and exported
reference-related types/constants.
- Added an `issue_reference_mentions` table, idempotent migration DDL,
schema exports, and database documentation.
- Added server-side issue reference services, route integration,
activity summaries, and a backfill command for existing issue content.
- Added UI reference pills, related-work panels, markdown/editor mention
handling, and issue detail/property rendering updates.
- Added focused shared, server, and UI tests for parsing, persistence,
display, and related-work behavior.
- Rebased `PAP-735-first-class-task-references` cleanly onto
`public-gh/master`; no `pnpm-lock.yaml` changes are included.

## Verification

- `pnpm -r typecheck`
- `pnpm test:run packages/shared/src/issue-references.test.ts
server/src/__tests__/issue-references-service.test.ts
ui/src/components/IssueRelatedWorkPanel.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownBody.test.tsx`

## Risks

- Medium risk because this adds a new issue-reference persistence path
that touches shared parsing, database schema, server routes, and UI
rendering.
- Migration risk is mitigated by `CREATE TABLE IF NOT EXISTS`, guarded
foreign-key creation, and `CREATE INDEX IF NOT EXISTS` statements so
users who have applied an older local version of the numbered migration
can re-run safely.
- UI risk is limited by focused component coverage, but reviewers should
still manually inspect issue detail pages containing ticket references
before merge.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-using shell workflow with
repository inspection, git rebase/push, typecheck, and focused Vitest
verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: dotta <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-21 10:02:52 -05:00

214 lines
5.9 KiB
TypeScript

import type { CSSProperties } from "react";
import {
parseAgentMentionHref,
parseIssueReferenceHref,
parseProjectMentionHref,
parseSkillMentionHref,
parseUserMentionHref,
} from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
export type ParsedMentionChip =
| {
kind: "agent";
agentId: string;
icon: string | null;
}
| {
kind: "issue";
identifier: string;
}
| {
kind: "project";
projectId: string;
color: string | null;
}
| {
kind: "user";
userId: string;
}
| {
kind: "skill";
skillId: string;
slug: string | null;
};
const iconMaskCache = new Map<string, string>();
export function parseMentionChipHref(href: string): ParsedMentionChip | null {
const issue = parseIssueReferenceHref(href);
if (issue) {
return {
kind: "issue",
identifier: issue.identifier,
};
}
const agent = parseAgentMentionHref(href);
if (agent) {
return {
kind: "agent",
agentId: agent.agentId,
icon: agent.icon,
};
}
const project = parseProjectMentionHref(href);
if (project) {
return {
kind: "project",
projectId: project.projectId,
color: project.color,
};
}
const user = parseUserMentionHref(href);
if (user) {
return {
kind: "user",
userId: user.userId,
};
}
const skill = parseSkillMentionHref(href);
if (skill) {
return {
kind: "skill",
skillId: skill.skillId,
slug: skill.slug,
};
}
return null;
}
export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSProperties | undefined {
const style: CSSProperties & Record<string, string> = {};
if (mention.kind === "project" && mention.color) {
const projectStyle = projectMentionColors(mention.color);
Object.assign(style, projectStyle);
style["--paperclip-mention-project-color"] = mention.color;
}
if (mention.kind === "agent") {
const iconMask = buildAgentIconMask(mention.icon);
if (iconMask) {
style["--paperclip-mention-icon-mask"] = iconMask;
}
}
return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined;
}
export function applyMentionChipDecoration(element: HTMLElement, mention: ParsedMentionChip) {
clearMentionChipDecoration(element);
element.dataset.mentionKind = mention.kind;
element.setAttribute("contenteditable", "false");
element.classList.add("paperclip-mention-chip", `paperclip-mention-chip--${mention.kind}`);
if (mention.kind === "project") {
element.classList.add("paperclip-project-mention-chip");
}
const style = mentionChipInlineStyle(mention);
if (!style) return;
for (const [key, value] of Object.entries(style)) {
if (typeof value === "string") {
if (key.startsWith("--")) {
element.style.setProperty(key, value);
} else {
(element.style as CSSStyleDeclaration & Record<string, string>)[key] = value;
}
}
}
}
export function clearMentionChipDecoration(element: HTMLElement) {
delete element.dataset.mentionKind;
element.classList.remove(
"paperclip-mention-chip",
"paperclip-mention-chip--agent",
"paperclip-mention-chip--issue",
"paperclip-mention-chip--project",
"paperclip-mention-chip--user",
"paperclip-mention-chip--skill",
"paperclip-project-mention-chip",
);
element.removeAttribute("contenteditable");
element.style.removeProperty("border-color");
element.style.removeProperty("background-color");
element.style.removeProperty("color");
element.style.removeProperty("--paperclip-mention-project-color");
element.style.removeProperty("--paperclip-mention-icon-mask");
}
function projectMentionColors(color: string): Pick<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
const rgb = hexToRgb(color);
if (!rgb) return {};
return {
borderColor: color,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
color: pickTextColorForPillBg(color),
};
}
function buildAgentIconMask(iconName: string | null): string | null {
const cacheKey = iconName ?? "__default__";
const cached = iconMaskCache.get(cacheKey);
if (cached) return cached;
const Icon = getAgentIcon(iconName);
const iconNode = resolveLucideIconNode(Icon);
if (!Array.isArray(iconNode) || iconNode.length === 0) return null;
const body = iconNode.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
.filter(([key]) => key !== "key")
.map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`)
.join(" ");
return `<${tag}${attrString ? ` ${attrString}` : ""}></${tag}>`;
}).join("");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ` +
`fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" ` +
`stroke-linejoin="round">${body}</svg>`;
const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
iconMaskCache.set(cacheKey, url);
return url;
}
function resolveLucideIconNode(
icon: unknown,
): Array<[string, Record<string, string>]> | null {
const staticIconNode = (
icon as {
iconNode?: Array<[string, Record<string, string>]>;
}
).iconNode;
if (Array.isArray(staticIconNode) && staticIconNode.length > 0) {
return staticIconNode;
}
const render = (
icon as {
render?: (props: Record<string, unknown>, ref: unknown) => {
props?: { iconNode?: Array<[string, Record<string, string>]> };
} | null;
}
).render;
const rendered = typeof render === "function" ? render({}, null) : null;
const renderedIconNode = rendered?.props?.iconNode;
return Array.isArray(renderedIconNode) && renderedIconNode.length > 0
? renderedIconNode
: null;
}
function escapeAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}