feat: @project mentions with colored chips in markdown and editors
Add project mention system using project:// URI scheme with optional color parameter. Mentions render as colored pill chips in markdown bodies and the WYSIWYG editor. Autocomplete in editors shows both agents and projects. Server extracts mentioned project IDs from issue content and returns them in the issue detail response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>();
|
||||
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;
|
||||
|
||||
@@ -217,6 +217,19 @@ export function projectService(db: Db) {
|
||||
return attachWorkspaces(db, withGoals);
|
||||
},
|
||||
|
||||
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
||||
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<ProjectWithGoals | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
|
||||
Reference in New Issue
Block a user