feat(editor): Linear-style issue identifier autolink (MUL-4241) (#5090)

* feat(editor): Linear-style issue identifier autolink (MUL-4241)

Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue
chips and can be typed/pasted into a real mention, instead of staying inert
text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the
Phase 3 batch resolve API is intentionally deferred.

Phase 1 — readonly render autolink
- Pure, markdown-aware detector `preprocessIssueIdentifiers` in
  @multica/ui/markdown rewrites bare identifiers to
  `[MUL-123](mention://issue/MUL-123)`, skipping code, existing links,
  URLs, and file/path tokens. Runs before linkify/file-card.
- `isIssueIdentifier` distinguishes a bare identifier from a real mention
  UUID at render time (a UUID never matches the identifier pattern).
- Chat markdown and comment/description readonly both resolve identifiers
  to a real issue via a workspace-scoped, exact-match TanStack Query
  (`issueIdentifierOptions`), rendering a chip on a hit and plain text on a
  miss / cross-workspace / while loading. The exact `identifier ===` filter
  enforces the workspace prefix, since the backend search matches by number.
- Autolink is opt-in per surface; the shared editable preprocess pipeline is
  untouched so editable content is never rewritten with fake mentions.

Phase 2 — editable editor input/paste
- Async ProseMirror plugin resolves a completed identifier (boundary typed
  after it, or found in pasted text) and swaps it for an issue mention node,
  serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine
  user edits seed candidates (programmatic setContent is gated), so opening
  existing content never rewrites it. Resolver injected from the setup layer;
  no React hooks inside the extension.

Tests: detector (code/link/url/path skips, dedupe), core resolver (exact
match, wrong-prefix miss, empty response, key shape), chat + readonly render
(hit/miss/code/canonical), and the editable extension (type/paste/miss/
inline-code/mount-safe/incomplete-token).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241)

Howard final-review blocker: the async resolve rescanned the whole document
for every occurrence of the resolved identifier, so completing a new `MUL-1`
also rewrote a pre-existing `MUL-1` the user never touched (persisted-content
rewrite; violated "opening existing content is not rewritten").

Fix: capture the specific candidate range(s) a user transaction introduces —
the token before the caret when typing, the tokens inside the pasted slice on
paste — into plugin state, mapping each range forward on every subsequent
transaction. After async resolve, replace ONLY those mapped ranges, verifying
each still holds exactly that identifier with intact boundaries and no
code/link mark. No document-wide scan by identifier. Also skip link-marked
text at capture so an existing link label is never converted.

Regression tests: (1) typing a new MUL-1 converts only the new occurrence,
not a pre-existing identical one; (2) paste converts identifiers inside the
paste range but leaves an identical one outside it untouched; (3) an
identifier already carrying an explicit link mark is not replaced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-09 21:46:33 +08:00
committed by GitHub
parent c6783efd88
commit 3f02083fec
18 changed files with 1223 additions and 23 deletions

View File

@@ -3,12 +3,18 @@ import { QueryClient } from "@tanstack/react-query";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import type { Issue, ListIssuesParams, ListIssuesResponse } from "../types";
import type {
Issue,
ListIssuesParams,
ListIssuesResponse,
SearchIssuesResponse,
} from "../types";
import {
CHILDREN_BY_PARENTS_CHUNK_SIZE,
PROJECT_GANTT_MAX_ISSUES,
PROJECT_GANTT_PAGE_LIMIT,
childrenByParentsOptions,
issueIdentifierOptions,
issueKeys,
projectGanttIssuesOptions,
} from "./queries";
@@ -54,6 +60,16 @@ function installFakeChildrenApi(
setApiInstance({ listChildrenByParents } as unknown as ApiClient);
}
function installFakeSearchApi(
searchIssues: (params: { q: string }) => Promise<SearchIssuesResponse>,
) {
setApiInstance({ searchIssues } as unknown as ApiClient);
}
function makeSearchResult(idx: number, identifier: string) {
return { ...makeIssue(idx), identifier, match_source: "title" as const };
}
describe("projectGanttIssuesOptions", () => {
let qc: QueryClient;
@@ -214,3 +230,68 @@ describe("childrenByParentsOptions chunking", () => {
expect(grouped.get(lastId)).toHaveLength(1);
});
});
describe("issueIdentifierOptions", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
it("returns the issue whose identifier exactly matches the query", async () => {
const searchIssues = vi
.fn<(params: { q: string }) => Promise<SearchIssuesResponse>>()
.mockResolvedValue({
issues: [makeSearchResult(7, "MUL-7")],
total: 1,
});
installFakeSearchApi(searchIssues);
const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "MUL-7"));
expect(data?.id).toBe("issue-7");
expect(searchIssues).toHaveBeenCalledWith(
expect.objectContaining({ q: "MUL-7" }),
);
});
it("returns null when no result's identifier matches (wrong prefix / number-only hit)", async () => {
// Backend number-match returns MUL-7 for a TES-7 query; exact filter rejects it.
const searchIssues = vi
.fn<(params: { q: string }) => Promise<SearchIssuesResponse>>()
.mockResolvedValue({
issues: [makeSearchResult(7, "MUL-7")],
total: 1,
});
installFakeSearchApi(searchIssues);
const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "TES-7"));
expect(data).toBeNull();
});
it("returns null on an empty (or malformed→empty) search response", async () => {
const searchIssues = vi
.fn<(params: { q: string }) => Promise<SearchIssuesResponse>>()
.mockResolvedValue({ issues: [], total: 0 });
installFakeSearchApi(searchIssues);
const data = await qc.fetchQuery(issueIdentifierOptions(WS_ID, "MUL-999"));
expect(data).toBeNull();
});
it("keys the query by workspace and identifier", () => {
expect(issueKeys.identifier(WS_ID, "MUL-7")).toEqual([
"issues",
WS_ID,
"identifier",
"MUL-7",
]);
});
});

View File

@@ -57,6 +57,9 @@ export const issueKeys = {
[...issueKeys.projectGanttAll(wsId), projectId] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
/** Resolve a bare issue identifier (e.g. "MUL-123") to an issue. */
identifier: (wsId: string, identifier: string) =>
[...issueKeys.all(wsId), "identifier", identifier] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
/** Prefix for invalidating all batched-children queries in a workspace. */
@@ -410,6 +413,37 @@ export function issueDetailOptions(wsId: string, id: string) {
});
}
/**
* Resolve a bare issue identifier ("MUL-123") to its issue, or `null`.
*
* Backs the Linear-style autolink: the backend `q` search matches an
* identifier on issue NUMBER only (prefix-agnostic — `MUL-123` and `TES-123`
* both hit number 123), so the exact `identifier === value` filter here is
* what enforces the workspace prefix. A non-existent or wrong-prefix
* identifier resolves to `null` and renders as plain text.
*
* Server state → TanStack Query; the key includes `wsId` and the identifier,
* so identical identifiers across the app share one request. Caller gates
* `enabled` (identifier shape + workspace prefix).
*/
export function issueIdentifierOptions(wsId: string, identifier: string) {
return queryOptions({
queryKey: issueKeys.identifier(wsId, identifier),
queryFn: async ({ signal }) => {
const res = await api.searchIssues({
q: identifier,
limit: 10,
include_closed: true,
signal,
});
return res.issues.find((i) => i.identifier === identifier) ?? null;
},
// Identifier→issue mapping is effectively immutable; avoid refetch churn
// when the same key renders across many comments/messages.
staleTime: 5 * 60_000,
});
}
export function childIssueProgressOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.childProgress(wsId),

View File

@@ -12,6 +12,7 @@ import { CODE_LIGATURE_CLASS } from '@multica/ui/lib/code-style'
import { CodeBlock, InlineCode } from './CodeBlock'
import { isAllowedFileCardHref, preprocessFileCards } from './file-cards'
import { preprocessLinks } from './linkify'
import { preprocessIssueIdentifiers } from './issue-identifiers'
import { preprocessMentionShortcodes } from './mentions'
import 'katex/dist/katex.min.css'
import './markdown.css'
@@ -75,6 +76,15 @@ export interface MarkdownProps {
* the views-package `<Attachment>` component.
*/
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
/**
* When true, bare issue identifiers (e.g. `MUL-123`, `TES-1`) are rewritten
* to `mention://issue/<identifier>` links so `renderMention` can resolve them
* to a navigable issue chip. Off by default — enable only on surfaces whose
* `renderMention` knows how to resolve an identifier (see the app wrapper in
* packages/views/common/markdown.tsx). Detection is markdown-aware: code,
* existing links, URLs, and file/path tokens are skipped.
*/
autolinkIssueIdentifiers?: boolean
}
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
@@ -442,22 +452,27 @@ export function Markdown({
renderMention,
renderImage,
renderFileCard,
cdnDomain
cdnDomain,
autolinkIssueIdentifiers
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
)
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content
// Preprocess: convert mention shortcodes, bare issue identifiers, raw URLs,
// and file cards to renderable content. Issue-identifier autolinking runs
// BEFORE linkify/file-card so those passes treat the rewritten spans as
// existing markdown links and skip them.
const processedContent = React.useMemo(
() => {
let result = preprocessMentionShortcodes(children)
if (autolinkIssueIdentifiers) result = preprocessIssueIdentifiers(result)
result = preprocessLinks(result)
result = preprocessFileCards(result, cdnDomain ?? '')
return result
},
[children, cdnDomain]
[children, cdnDomain, autolinkIssueIdentifiers]
)
return (

View File

@@ -2,6 +2,11 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export {
preprocessIssueIdentifiers,
isIssueIdentifier,
ISSUE_IDENTIFIER_PATTERN,
} from './issue-identifiers'
export { preprocessMentionShortcodes } from './mentions'
export {
preprocessFileCards,

View File

@@ -0,0 +1,108 @@
import {
detectLinks,
findCodeRanges,
findMarkdownLinkRanges,
isInsideCode,
rangesOverlap,
} from './linkify'
/**
* Linear-style bare issue identifier autolinking for markdown preprocessing.
*
* Rewrites bare issue identifiers like `MUL-123` / `TES-1` into canonical
* mention links `[MUL-123](mention://issue/MUL-123)`, so the shared markdown
* `a`/mention renderers can route them to a navigable issue chip.
*
* This module is intentionally PURE (packages/ui): it has no workspace or API
* access. It only DETECTS candidate identifiers — whether one resolves to a
* real issue in the current workspace is decided later, in the views layer,
* via an exact-match lookup. A candidate that resolves to nothing renders as
* plain text, so over-detection here is harmless (at most one deduped lookup).
* We still skip code, existing links, URLs, and file/path tokens so non-prose
* content is never rewritten.
*
* NOTE: the `mention://issue/<id>` transport is reused for both real UUID
* mentions and bare identifiers. Render sites tell them apart by the shape of
* the id segment (`isIssueIdentifier`) — a UUID never matches the identifier
* pattern, so the dispatch is unambiguous.
*/
// Detection form (global). Prefix is one uppercase letter followed by uppercase
// alphanumerics, then `-<number>`. Case-sensitive on purpose: lowercase
// `abc-1` in prose is almost never an issue reference, and matching it would
// linkify ordinary hyphenated words. The look-around excludes alphanumerics,
// `_`, and `-` on both sides so we only match standalone tokens.
const IDENTIFIER_RE = /(?<![A-Za-z0-9_-])([A-Z][A-Z0-9]*-\d+)(?![A-Za-z0-9_-])/g
// Anchored single-token form — used by render sites to distinguish a bare
// identifier (carried through `mention://issue/<identifier>`) from a real UUID.
export const ISSUE_IDENTIFIER_PATTERN = /^[A-Z][A-Z0-9]*-\d+$/
/**
* True when `value` is a bare issue identifier (e.g. "MUL-123"). A UUID never
* matches (lowercase hex, four dashes), so this cleanly separates autolinked
* identifiers from real mention UUIDs at render time.
*/
export function isIssueIdentifier(value: string): boolean {
return ISSUE_IDENTIFIER_PATTERN.test(value)
}
/**
* Rewrite bare issue identifiers into canonical `mention://issue/<identifier>`
* markdown links. Runs BEFORE `preprocessLinks`/`preprocessFileCards` so those
* passes see the rewritten spans as existing markdown links and skip them.
*
* Skipped contexts (never rewritten):
* - fenced code blocks, inline code, and math (findCodeRanges)
* - existing markdown links / images (findMarkdownLinkRanges)
* - detected URLs / emails / file paths (detectLinks)
* - dotted filename tails like `ABC-123.ts` and path segments like `FOO-1/bar`
*/
export function preprocessIssueIdentifiers(text: string): string {
// Cheap early-out: no `<UPPER>…-<DIGIT>` shape at all.
if (!/[A-Z][A-Z0-9]*-\d/.test(text)) return text
const codeRanges = findCodeRanges(text)
const linkRanges = findMarkdownLinkRanges(text)
const detectedLinks = detectLinks(text)
IDENTIFIER_RE.lastIndex = 0
let result = ''
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = IDENTIFIER_RE.exec(text)) !== null) {
const identifier = match[1]
if (!identifier) continue
const start = match.index
const end = start + identifier.length
const range = { start, end }
// Inside fenced/inline code or math.
if (isInsideCode(start, codeRanges)) continue
// Inside an existing markdown link/image (label OR destination).
if (linkRanges.some((r) => rangesOverlap(range, r))) continue
// Inside a detected URL / email / file path.
if (detectedLinks.some((l) => rangesOverlap(range, l))) continue
// Dotted continuation such as `ABC-123.ts` (filename) or `ABC-123.tar.gz`:
// a `.` immediately followed by an alphanumeric means the token is part of
// a larger dotted name. A trailing `.` before whitespace/EOL is a sentence
// end ("see MUL-1.") and stays linkable.
const after = text[end]
if (after === '.' && /[A-Za-z0-9]/.test(text[end + 1] ?? '')) continue
// Path segment such as `FOO-1/bar` or `foo/BAR-1` — a `/` on either side
// signals a path rather than a standalone reference.
if (after === '/' || text[start - 1] === '/') continue
// Embedded in a dotted name on the left (`file.MUL-1`).
if (text[start - 1] === '.') continue
result += text.slice(lastIndex, start)
result += `[${identifier}](mention://issue/${identifier})`
lastIndex = end
}
if (lastIndex === 0) return text
result += text.slice(lastIndex)
return result
}

View File

@@ -55,7 +55,7 @@ interface DetectedLink {
end: number
}
interface CodeRange {
export interface CodeRange {
start: number
end: number
}
@@ -64,7 +64,7 @@ interface CodeRange {
* Find all code block and inline code ranges in text
* These ranges should be excluded from link detection
*/
function findCodeRanges(text: string): CodeRange[] {
export function findCodeRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
// Find fenced code blocks (```...```)
@@ -112,7 +112,7 @@ function findCodeRanges(text: string): CodeRange[] {
/**
* Check if a position is inside any code range
*/
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
export function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
@@ -164,7 +164,7 @@ function findInlineLinkEnd(text: string, openParenIndex: number): number {
* Find existing markdown link/image spans so auto-linkification does not create
* nested links inside their labels or destinations.
*/
function findMarkdownLinkRanges(text: string): CodeRange[] {
export function findMarkdownLinkRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
for (let i = 0; i < text.length; i++) {
@@ -223,7 +223,7 @@ function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): bool
/**
* Check if ranges overlap
*/
function rangesOverlap(
export function rangesOverlap(
a: { start: number; end: number },
b: { start: number; end: number }
): boolean {

View File

@@ -1,9 +1,15 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Markdown as MarkdownBase } from "@multica/ui/markdown";
import { Markdown } from "./markdown";
const { resolveMock } = vi.hoisted(() => ({ resolveMock: vi.fn() }));
vi.mock("../issues/hooks", () => ({
useResolveIssueIdentifier: (identifier: string) => resolveMock(identifier),
}));
vi.mock("@multica/core/config", () => ({
useConfigStore: (selector: (state: { cdnDomain: string }) => unknown) =>
selector({ cdnDomain: "" }),
@@ -92,6 +98,54 @@ describe("Markdown", () => {
});
});
describe("Markdown bare issue identifier autolink", () => {
afterEach(() => {
resolveMock.mockReset();
});
it("renders a resolved bare identifier as an issue chip", () => {
resolveMock.mockImplementation((id: string) =>
id === "MUL-1" ? { id: "issue-1", identifier: "MUL-1" } : null,
);
render(<Markdown>{"Related to MUL-1 today"}</Markdown>);
expect(screen.getByTestId("issue-mention-card")).toHaveTextContent("issue-1");
expect(resolveMock).toHaveBeenCalledWith("MUL-1");
});
it("leaves an unresolved bare identifier as plain text", () => {
resolveMock.mockReturnValue(null);
render(<Markdown>{"Related to MUL-999 today"}</Markdown>);
expect(screen.queryByTestId("issue-mention-card")).toBeNull();
expect(screen.getByText(/Related to/)).toHaveTextContent("MUL-999");
});
it("does not autolink identifiers inside inline code", () => {
resolveMock.mockReturnValue(null);
render(<Markdown>{"use `MUL-1` here"}</Markdown>);
expect(resolveMock).not.toHaveBeenCalled();
expect(screen.queryByTestId("issue-mention-card")).toBeNull();
});
it("routes a canonical UUID mention straight to the chip (no identifier resolve)", () => {
render(
<Markdown>
{"[MUL-2](mention://issue/00000000-0000-0000-0000-000000000002)"}
</Markdown>,
);
expect(screen.getByTestId("issue-mention-card")).toHaveTextContent(
"00000000-0000-0000-0000-000000000002",
);
expect(resolveMock).not.toHaveBeenCalled();
});
});
// The base renderer uses a plain <img>; exercising it here (instead of the
// views wrapper, which swaps in <Attachment>) lets us assert the sanitized
// `src` directly. Covers the two gates that used to blank data-URI images:

View File

@@ -5,11 +5,13 @@ import {
Markdown as MarkdownBase,
type MarkdownProps as MarkdownBaseProps,
type RenderMode,
isIssueIdentifier,
} from "@multica/ui/markdown";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useWorkspacePaths } from "@multica/core/paths";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { useResolveIssueIdentifier } from "../issues/hooks";
import { ProjectChip } from "../projects/components/project-chip";
import { AppLink } from "../navigation";
import {
@@ -46,6 +48,18 @@ function ProjectMentionCard({ projectId }: { projectId: string }): React.ReactNo
);
}
/**
* Autolinked bare identifier (e.g. `MUL-123`) routed through
* `mention://issue/<identifier>`. Resolves the identifier to a real issue in
* the current workspace; renders a navigable chip on a hit, plain text on a
* miss / while loading / cross-workspace.
*/
function AutolinkedIssueMention({ identifier }: { identifier: string }): React.ReactNode {
const issue = useResolveIssueIdentifier(identifier);
if (!issue) return identifier;
return <IssueMentionCard issueId={issue.id} fallbackLabel={identifier} />;
}
function defaultRenderMention({
type,
id,
@@ -54,6 +68,11 @@ function defaultRenderMention({
id: string;
}): React.ReactNode {
if (type === "issue") {
// A bare identifier (from the autolink preprocessor) is carried as the id
// segment; a real mention carries a UUID. Dispatch on the id shape.
if (isIssueIdentifier(id)) {
return <AutolinkedIssueMention identifier={id} />;
}
return <IssueMentionCard issueId={id} />;
}
if (type === "project") {
@@ -110,6 +129,7 @@ export function Markdown(props: MarkdownProps): React.JSX.Element {
renderImage={renderImage}
renderFileCard={renderFileCard}
cdnDomain={cdnDomain}
autolinkIssueIdentifiers
{...rest}
/>
</AttachmentDownloadProvider>

View File

@@ -44,6 +44,9 @@ import { cn } from "@multica/ui/lib/utils";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useQueryClient } from "@tanstack/react-query";
import { issueIdentifierOptions } from "@multica/core/issues/queries";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { isIssueIdentifier } from "@multica/ui/markdown";
import type { Attachment } from "@multica/core/types";
import {
parseMarkdownChunked,
@@ -51,6 +54,7 @@ import {
type MarkdownManagerLike,
} from "./utils/parse-markdown-chunked";
import type { MentionItem } from "./extensions/mention-suggestion";
import type { IssueIdentifierResolver } from "./extensions/issue-identifier-autolink";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
@@ -304,6 +308,35 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const queryClient = useQueryClient();
// Linear-style bare identifier autolink resolver. Fully lazy — it runs only
// on user input, never on render, so it adds no query hook to this widely
// used component. It reads the current workspace from the query cache (via
// the slug ref) and returns null outside a workspace, for non-identifier
// tokens, or when the prefix can't match this workspace, so no network call
// happens for those; the exact-match filter enforces correctness.
const resolveIssueIdentifierRef = useRef<IssueIdentifierResolver | undefined>(
undefined,
);
resolveIssueIdentifierRef.current = async (identifier) => {
if (!isIssueIdentifier(identifier)) return null;
const slug = workspaceSlugRef.current;
if (!slug) return null;
const workspaces = await queryClient.fetchQuery(workspaceListOptions());
const ws = workspaces.find((w) => w.slug === slug);
if (!ws) return null;
const prefix = ws.issue_prefix;
if (
prefix &&
!identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`)
) {
return null;
}
const issue = await queryClient.fetchQuery(
issueIdentifierOptions(ws.id, identifier),
);
return issue ? { id: issue.id, identifier: issue.identifier } : null;
};
const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : "";
// With `immediatelyRender: false` the Tiptap instance is created after
// mount, so an imperative `focus()` fired on the same tick (e.g. chat
@@ -364,6 +397,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
getMentionContextItems: () => mentionContextItemsRef.current,
enableSlashCommands,
slashCommandMode,
resolveIssueIdentifierRef,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;

View File

@@ -39,6 +39,10 @@ import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { escapeMarkdownLabel } from "../utils/escape-markdown-label";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
import {
createIssueIdentifierAutolinkExtension,
type IssueIdentifierResolver,
} from "./issue-identifier-autolink";
import { SlashCommandExtension } from "./slash-command-extension";
import { createSlashCommandSuggestion, createBuiltinCommandSuggestion } from "./slash-command-suggestion";
import { CodeBlockView } from "./code-block-view";
@@ -152,6 +156,14 @@ export interface EditorExtensionsOptions {
* - "command" — the fixed built-in command menu (issue comments), e.g. /note.
*/
slashCommandMode?: "skill" | "command";
/**
* Resolver for Linear-style bare issue-identifier autolinking. When present
* (and mentions are enabled), typing a boundary after `MUL-123` or pasting
* text with identifiers resolves them and swaps in real issue mentions. A
* ref so the editor is created once while the resolver reads live workspace
* context; the setup layer owns React Query + workspace access.
*/
resolveIssueIdentifierRef?: RefObject<IssueIdentifierResolver | undefined>;
}
export function createEditorExtensions(
@@ -215,6 +227,16 @@ export function createEditorExtensions(
? { suggestion: createMentionSuggestion(options.queryClient, { mode: options.mentionMode, getContextItems: options.getMentionContextItems }) }
: {}),
}),
// Linear-style bare identifier → issue mention. Attached only when a
// resolver is provided AND mention creation is enabled (an editor that
// suppresses new mentions should not synthesise them from identifiers).
...(!options.disableMentions && options.resolveIssueIdentifierRef
? [
createIssueIdentifierAutolinkExtension({
resolveRef: options.resolveIssueIdentifierRef,
}),
]
: []),
SlashCommandExtension.configure({
HTMLAttributes: { class: "slash-command" },
suggestion: !options.enableSlashCommands

View File

@@ -0,0 +1,249 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import { Markdown } from "@tiptap/markdown";
import type { RefObject } from "react";
import { BaseMentionExtension } from "./mention-extension";
import {
createIssueIdentifierAutolinkExtension,
type IssueIdentifierResolver,
} from "./issue-identifier-autolink";
const LinkExtension = Link.extend({ inclusive: false }).configure({
openOnClick: false,
autolink: false,
});
/** Count canonical issue mentions in serialised markdown. */
function mentionCount(markdown: string): number {
return markdown.match(/mention:\/\/issue\//g)?.length ?? 0;
}
// The real mention extension renders via a React NodeView (IssueChip → workspace
// hooks) that needs providers we don't mount here. Swap in a plain-DOM NodeView
// so the node still serialises through the inherited renderMarkdown, without
// pulling React into the jsdom editor.
const TestMention = BaseMentionExtension.extend({
addNodeView() {
return () => ({ dom: document.createElement("span") });
},
});
const resolveMock = vi.fn<IssueIdentifierResolver>();
const resolveRef: RefObject<IssueIdentifierResolver | undefined> = {
current: (identifier) => resolveMock(identifier),
};
let editor: Editor | null = null;
function makeEditor(): Editor {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
StarterKit.configure({ link: false }),
LinkExtension,
TestMention,
createIssueIdentifierAutolinkExtension({ resolveRef }),
Markdown.configure({ indentation: { style: "space", size: 3 } }),
],
});
}
/** Flush the resolver microtask + the follow-up replacement dispatch. */
function flush(): Promise<void> {
return new Promise((r) => setTimeout(r, 0));
}
/** Simulate user typing `text` at position 1 (empty paragraph start). */
function typeAt1(ed: Editor, text: string): void {
const tr = ed.state.tr.insertText(text, 1);
ed.view.dispatch(tr);
}
beforeEach(() => {
resolveMock.mockReset();
});
afterEach(() => {
editor?.destroy();
editor = null;
document.body.innerHTML = "";
});
describe("createIssueIdentifierAutolinkExtension", () => {
it("converts a completed identifier into a canonical issue mention", async () => {
resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" });
editor = makeEditor();
// Typing "MUL-1 " leaves the caret right after the boundary space, which
// completes the previous token.
typeAt1(editor, "MUL-1 ");
await flush();
expect(resolveMock).toHaveBeenCalledWith("MUL-1");
expect(editor.getMarkdown().trim()).toBe(
"[MUL-1](mention://issue/uuid-1)",
);
});
it("leaves an unresolvable identifier as plain text", async () => {
resolveMock.mockResolvedValue(null);
editor = makeEditor();
typeAt1(editor, "MUL-9 ");
await flush();
expect(resolveMock).toHaveBeenCalledWith("MUL-9");
const md = editor.getMarkdown();
expect(md).toContain("MUL-9");
expect(md).not.toContain("mention://issue");
});
it("converts identifiers found inside pasted text", async () => {
resolveMock.mockResolvedValue({ id: "uuid-2", identifier: "MUL-2" });
editor = makeEditor();
const tr = editor.state.tr.insertText("See MUL-2 now", 1);
tr.setMeta("paste", true);
editor.view.dispatch(tr);
await flush();
expect(resolveMock).toHaveBeenCalledWith("MUL-2");
expect(editor.getMarkdown().trim()).toBe(
"See [MUL-2](mention://issue/uuid-2) now",
);
});
it("does not convert content set programmatically (open ≠ rewrite)", async () => {
resolveMock.mockResolvedValue({ id: "uuid-3", identifier: "MUL-3" });
editor = makeEditor();
// setContent uses emitUpdate:false (preventUpdate) — the same path the real
// editor uses on mount and WS-driven resets.
editor.commands.setContent("MUL-3 stays", {
emitUpdate: false,
contentType: "markdown",
});
await flush();
expect(resolveMock).not.toHaveBeenCalled();
expect(editor.getMarkdown()).toContain("MUL-3");
expect(editor.getMarkdown()).not.toContain("mention://issue");
});
it("does not convert an identifier inside inline code", async () => {
resolveMock.mockResolvedValue({ id: "uuid-4", identifier: "MUL-4" });
editor = makeEditor();
const codeMark = editor.schema.marks.code!.create();
const tr = editor.state.tr.insert(
1,
editor.schema.text("MUL-4 ", [codeMark]),
);
editor.view.dispatch(tr);
await flush();
expect(resolveMock).not.toHaveBeenCalled();
expect(editor.getMarkdown()).not.toContain("mention://issue");
});
it("does not fire while the identifier is still being typed (no boundary yet)", async () => {
resolveMock.mockResolvedValue({ id: "uuid-5", identifier: "MUL-5" });
editor = makeEditor();
typeAt1(editor, "MUL-5");
await flush();
expect(resolveMock).not.toHaveBeenCalled();
expect(editor.getMarkdown()).not.toContain("mention://issue");
});
// --- blocker regressions: replace ONLY the captured range ---------------
it("converts only the newly-typed occurrence, not a pre-existing identical one", async () => {
resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" });
editor = makeEditor();
// Pre-existing MUL-1 arrives via a programmatic (preventUpdate) set — the
// path the real editor uses on mount / WS resets — so it is not captured.
editor.commands.setContent("old MUL-1 here", {
emitUpdate: false,
contentType: "markdown",
});
await flush();
expect(resolveMock).not.toHaveBeenCalled();
// User now types a brand-new MUL-1 at the end of the paragraph.
const endOfPara = editor.state.doc.content.size - 1;
editor.view.dispatch(editor.state.tr.insertText(" MUL-1 ", endOfPara));
await flush();
const md = editor.getMarkdown();
// Exactly the new occurrence became a mention; the old one stays text.
expect(mentionCount(md)).toBe(1);
expect(md).toContain("old MUL-1 here");
expect(md).toContain("mention://issue/uuid-1");
});
it("paste converts identifiers inside the paste range but not identical ones outside it", async () => {
resolveMock.mockImplementation(async (identifier) => {
const map: Record<string, string> = {
"MUL-2": "uuid-2",
"MUL-3": "uuid-3",
"MUL-9": "uuid-9",
};
const id = map[identifier];
return id ? { id, identifier } : null;
});
editor = makeEditor();
// Pre-existing (programmatic) MUL-9 outside any future paste range.
editor.commands.setContent("keep MUL-9 outside", {
emitUpdate: false,
contentType: "markdown",
});
await flush();
// Paste two identifiers at the very start of the doc.
const tr = editor.state.tr.insertText("MUL-2 plus MUL-3 x ", 1);
tr.setMeta("paste", true);
editor.view.dispatch(tr);
await flush();
const md = editor.getMarkdown();
// Both pasted identifiers converted; the outside MUL-9 did NOT.
expect(md).toContain("mention://issue/uuid-2");
expect(md).toContain("mention://issue/uuid-3");
expect(md).not.toContain("mention://issue/uuid-9");
expect(md).toContain("MUL-9");
expect(mentionCount(md)).toBe(2);
expect(resolveMock).not.toHaveBeenCalledWith("MUL-9");
});
it("does not replace an identifier that already carries an explicit link mark", async () => {
resolveMock.mockResolvedValue({ id: "uuid-1", identifier: "MUL-1" });
editor = makeEditor();
// A link-marked "MUL-1" (e.g. an existing markdown link label) followed by
// plain text, then the user types a boundary after it.
const linkMark = editor.schema.marks.link!.create({ href: "https://x.test" });
const linked = editor.schema.text("MUL-1", [linkMark]);
const trailing = editor.schema.text(" ");
const paragraph = editor.schema.nodes.paragraph!.create(null, [
linked,
trailing,
]);
editor.view.dispatch(
editor.state.tr.replaceWith(0, editor.state.doc.content.size, paragraph),
);
await flush();
expect(resolveMock).not.toHaveBeenCalled();
const md = editor.getMarkdown();
expect(md).not.toContain("mention://issue");
expect(md).toContain("https://x.test");
});
});

View File

@@ -0,0 +1,348 @@
/**
* IssueIdentifierAutolink — Linear-style live autolinking of bare issue
* identifiers (e.g. `MUL-123`) in the editable editor.
*
* When the user finishes a bare identifier by typing a boundary character
* (space / punctuation) after it, or pastes text containing identifiers, the
* completed token is resolved against the workspace and — on an exact match —
* replaced with a real `issue` mention node. On save the mention serialises to
* the canonical `[MUL-123](mention://issue/<uuid>)`.
*
* Resolution is async, so this is NOT a synchronous Tiptap InputRule/PasteRule.
* A ProseMirror plugin captures the SPECIFIC candidate range(s) introduced by a
* user transaction — the token before the caret when typing, or the tokens
* inside the pasted slice — into plugin state, mapping each range forward on
* every subsequent transaction so it stays current across the async gap. A
* plugin `view` resolves the captured identifiers and replaces ONLY those
* mapped ranges (after re-verifying the range still holds exactly that
* identifier, with intact boundaries and no code/link mark). It never rescans
* the document by identifier, so a pre-existing or out-of-paste occurrence of
* the same identifier that the user did not touch is left untouched. Misses and
* errors are cached so a token is resolved at most once per editing session.
*
* Only genuine user edits seed candidates (never programmatic setContent), so
* merely opening existing content does not rewrite it. The resolver is injected
* (a ref) from the editor setup layer, which owns React Query + workspace
* context; this extension never touches React hooks.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorState, Transaction } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Mark, Node as PMNode, NodeType } from "@tiptap/pm/model";
import type { RefObject } from "react";
export interface ResolvedIssueRef {
/** Issue UUID. */
id: string;
/** Canonical identifier as returned by the server, e.g. "MUL-123". */
identifier: string;
}
export type IssueIdentifierResolver = (
identifier: string,
) => Promise<ResolvedIssueRef | null>;
export interface IssueIdentifierAutolinkOptions {
/**
* Ref to the resolver. A ref (not a bare function) so the editor is created
* once while the resolver always reads the latest workspace context.
*/
resolveRef: RefObject<IssueIdentifierResolver | undefined>;
}
// Boundary-delimited, case-sensitive identifier — same shape as the readonly
// detector in @multica/ui/markdown.
const IDENTIFIER_RE = /(?<![A-Za-z0-9_-])([A-Z][A-Z0-9]*-\d+)(?![A-Za-z0-9_-])/g;
const BOUNDARY_RE = /[A-Za-z0-9_-]/;
// Set on our own replacement transactions with the list of pending keys they
// consume, so `apply` drops them and never treats the replacement as user input.
const META_REMOVE = "issueIdentifierAutolinkRemove";
interface Candidate {
identifier: string;
from: number;
to: number;
}
interface PendingCandidate extends Candidate {
/** Unique per-plugin key correlating capture → async resolve → replace. */
key: number;
}
interface AutolinkPluginState {
pending: PendingCandidate[];
seq: number;
}
const pluginKey = new PluginKey<AutolinkPluginState>("issueIdentifierAutolink");
/** Text nodes that must never be autolinked (code and explicit-link contexts). */
function isSkippedTextNode(
marks: readonly Mark[],
parent: PMNode | null,
): boolean {
if (marks.some((m) => m.type.name === "code" || m.type.name === "link")) {
return true;
}
if (parent && parent.type.name === "codeBlock") return true;
return false;
}
/**
* Complete, standalone identifier tokens whose range intersects
* [rangeFrom, rangeTo). "Complete" means a trailing boundary char exists within
* the same text node — a token still being typed at the very end is skipped.
*/
function collectCandidates(
state: EditorState,
rangeFrom = 0,
rangeTo = Number.POSITIVE_INFINITY,
): Candidate[] {
const out: Candidate[] = [];
state.doc.descendants((node, pos, parent) => {
if (!node.isText || !node.text) return;
if (isSkippedTextNode(node.marks, parent)) return;
const text = node.text;
IDENTIFIER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = IDENTIFIER_RE.exec(text)) !== null) {
const identifier = m[1];
if (!identifier) continue;
const localEnd = m.index + identifier.length;
if (localEnd >= text.length) continue; // no trailing boundary in-node
const from = pos + m.index;
const to = pos + localEnd;
if (to <= rangeFrom || from >= rangeTo) continue; // outside window
out.push({ identifier, from, to });
}
});
return out;
}
/** Absolute [from,to) range spanned by a transaction's changes, or null. */
function changedRange(tr: Transaction): { from: number; to: number } | null {
let from = Number.POSITIVE_INFINITY;
let to = -1;
tr.mapping.maps.forEach((map) => {
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
from = Math.min(from, newStart);
to = Math.max(to, newEnd);
});
});
if (to < 0) return null;
return { from, to };
}
/**
* Candidates introduced by a single user transaction:
* - paste: every complete identifier INSIDE the pasted range only
* - typing: the token immediately before the cursor, i.e. the "previous
* token" completed by the boundary char that was just typed
*/
function candidatesFromUserTransaction(
tr: Transaction,
state: EditorState,
): Candidate[] {
const isPaste =
tr.getMeta("paste") === true || tr.getMeta("uiEvent") === "paste";
if (isPaste) {
const range = changedRange(tr);
if (!range) return [];
return collectCandidates(state, range.from, range.to);
}
const caret = state.selection.from;
const target = collectCandidates(state).find((c) => c.to === caret - 1);
return target ? [target] : [];
}
/**
* Verify a mapped candidate range still holds exactly `identifier` as plain
* text with intact boundaries — the async safety net before replacing.
*/
function rangeStillPlainIdentifier(
state: EditorState,
from: number,
to: number,
identifier: string,
): boolean {
const size = state.doc.content.size;
if (from < 0 || to > size || from >= to) return false;
if (state.doc.textBetween(from, to) !== identifier) return false;
let ok = true;
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (node.isText && isSkippedTextNode(node.marks, parent)) ok = false;
});
if (!ok) return false;
const before = from > 0 ? state.doc.textBetween(from - 1, from) : "";
const after = to < size ? state.doc.textBetween(to, to + 1) : "";
if (before && BOUNDARY_RE.test(before)) return false;
if (after && BOUNDARY_RE.test(after)) return false;
return true;
}
export function createIssueIdentifierAutolinkExtension(
options: IssueIdentifierAutolinkOptions,
): Extension {
return Extension.create({
name: "issueIdentifierAutolink",
addProseMirrorPlugins() {
const maybeMentionType = this.editor.schema.nodes.mention;
if (!maybeMentionType) return [];
// Alias to a definitely-defined const so the narrowing survives into the
// nested plugin `view`/`applyReady` closures.
const mentionType: NodeType = maybeMentionType;
const resolveRef = options.resolveRef;
return [
new Plugin<AutolinkPluginState>({
key: pluginKey,
state: {
init: () => ({ pending: [], seq: 0 }),
apply(tr, value, _oldState, newState): AutolinkPluginState {
let pending = value.pending;
let seq = value.seq;
// 1. Keep captured ranges current across every doc change, and
// drop ranges the edit invalidated (collapsed to empty).
if (tr.docChanged && pending.length > 0) {
pending = pending
.map((c) => ({
...c,
from: tr.mapping.map(c.from, 1),
to: tr.mapping.map(c.to, -1),
}))
.filter((c) => c.from < c.to);
}
// 2. Drop candidates consumed by our own replacement transaction.
const remove = tr.getMeta(META_REMOVE) as number[] | undefined;
if (remove && remove.length > 0) {
const rm = new Set(remove);
pending = pending.filter((c) => !rm.has(c.key));
}
// 3. Capture new candidates from a genuine user edit only (never
// programmatic setContent or our own replacement).
const isUserEdit =
tr.docChanged && !tr.getMeta("preventUpdate") && !remove;
if (isUserEdit) {
const fresh = candidatesFromUserTransaction(tr, newState);
if (fresh.length > 0) {
pending = pending.concat(
fresh.map((c) => ({ key: seq++, ...c })),
);
}
}
if (pending === value.pending && seq === value.seq) return value;
return { pending, seq };
},
},
view(view) {
// identifier → resolved ref (null = miss); resolved at most once.
const resultCache = new Map<string, ResolvedIssueRef | null>();
const inFlight = new Set<string>();
let destroyed = false;
let scheduled = false;
// Apply all ready (resolved) pending candidates on a microtask, so
// we never dispatch synchronously from inside `update`.
function scheduleApply(): void {
if (scheduled || destroyed) return;
scheduled = true;
void Promise.resolve().then(() => {
scheduled = false;
if (!destroyed) applyReady(view);
});
}
function applyReady(v: EditorView): void {
const st = pluginKey.getState(v.state);
if (!st || st.pending.length === 0) return;
const removeKeys: number[] = [];
const replacements: {
from: number;
to: number;
ref: ResolvedIssueRef;
}[] = [];
for (const c of st.pending) {
if (!resultCache.has(c.identifier)) continue;
const ref = resultCache.get(c.identifier);
removeKeys.push(c.key); // processed either way
if (
ref &&
rangeStillPlainIdentifier(v.state, c.from, c.to, c.identifier)
) {
replacements.push({ from: c.from, to: c.to, ref });
}
}
if (removeKeys.length === 0) return;
const { tr } = v.state;
// Right-to-left so earlier ranges stay valid as later ones change.
replacements.sort((a, b) => b.from - a.from);
for (const r of replacements) {
tr.replaceWith(
r.from,
r.to,
mentionType.create({
id: r.ref.id,
label: r.ref.identifier,
type: "issue",
}),
);
}
tr.setMeta(META_REMOVE, removeKeys);
v.dispatch(tr);
}
return {
update() {
if (destroyed) return;
const resolve = resolveRef.current;
if (!resolve) return;
const st = pluginKey.getState(view.state);
if (!st || st.pending.length === 0) return;
let anyReady = false;
for (const c of st.pending) {
if (resultCache.has(c.identifier)) {
anyReady = true;
continue;
}
if (inFlight.has(c.identifier)) continue;
inFlight.add(c.identifier);
Promise.resolve(resolve(c.identifier))
.then((ref) => {
resultCache.set(c.identifier, ref ?? null);
inFlight.delete(c.identifier);
scheduleApply();
})
.catch(() => {
// Treat resolve failures as a miss so we don't spin.
resultCache.set(c.identifier, null);
inFlight.delete(c.identifier);
scheduleApply();
});
}
if (anyReady) scheduleApply();
},
destroy() {
destroyed = true;
},
};
},
}),
];
},
});
}

View File

@@ -4,8 +4,16 @@ import type { ReactElement } from "react";
import { readFileSync } from "node:fs";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
const { getAttachmentTextContentMock, resolveIssueIdentifierMock } = vi.hoisted(
() => ({
getAttachmentTextContentMock: vi.fn(),
resolveIssueIdentifierMock: vi.fn(),
}),
);
vi.mock("../issues/hooks", () => ({
useResolveIssueIdentifier: (identifier: string) =>
resolveIssueIdentifierMock(identifier),
}));
vi.mock("@multica/core/api", () => ({
@@ -221,6 +229,41 @@ describe("ReadonlyContent issue mention Markdown", () => {
expect(getByTestId("issue-mention-card").textContent).toBe("MUL-123");
});
it("autolinks a resolved bare identifier as an issue mention card", () => {
resolveIssueIdentifierMock.mockImplementation((id: string) =>
id === "MUL-7" ? { id: "issue-7", identifier: "MUL-7" } : null,
);
const { getByTestId } = render(
<ReadonlyContent content="See MUL-7 for context" />,
);
expect(getByTestId("issue-mention-card").textContent).toBe("MUL-7");
expect(resolveIssueIdentifierMock).toHaveBeenCalledWith("MUL-7");
});
it("leaves an unresolved bare identifier as plain text", () => {
resolveIssueIdentifierMock.mockReturnValue(null);
const { container, queryByTestId } = render(
<ReadonlyContent content="See MUL-999 for context" />,
);
expect(queryByTestId("issue-mention-card")).toBeNull();
expect(container.textContent).toContain("MUL-999");
});
it("does not autolink a bare identifier inside inline code", () => {
resolveIssueIdentifierMock.mockReturnValue(null);
const { queryByTestId } = render(
<ReadonlyContent content={"use `MUL-7` here"} />,
);
expect(resolveIssueIdentifierMock).not.toHaveBeenCalled();
expect(queryByTestId("issue-mention-card")).toBeNull();
});
it("documents the CommonMark quoted-emphasis edge case before Korean particles", () => {
const unsafe = render(
<ReadonlyContent content={'**"무엇을 먼저 정해두고 시작할지"**가'} />,

View File

@@ -38,10 +38,11 @@ import type { Attachment } from "@multica/core/types";
import { useT } from "../i18n";
import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { useResolveIssueIdentifier } from "../issues/hooks";
import { ProjectChip } from "../projects/components/project-chip";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { isAllowedFileCardHref } from "@multica/ui/markdown";
import { isAllowedFileCardHref, isIssueIdentifier } from "@multica/ui/markdown";
import { preprocessMarkdown } from "./utils/preprocess";
import { highlightToHtml } from "./utils/highlight-markdown";
import { MermaidDiagram } from "./mermaid-diagram";
@@ -154,6 +155,18 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
);
}
/**
* Autolinked bare identifier (e.g. `MUL-123`) routed through
* `mention://issue/<identifier>` by the readonly preprocessor. Resolves to a
* real issue in the current workspace; renders a navigable mention on a hit,
* plain text on a miss / while loading / cross-workspace.
*/
function AutolinkedIssueMentionLink({ identifier }: { identifier: string }) {
const issue = useResolveIssueIdentifier(identifier);
if (!issue) return <>{identifier}</>;
return <IssueMentionLink issueId={issue.id} label={identifier} />;
}
function ProjectMentionLink({ projectId, label }: { projectId: string; label?: string }) {
const { push, openInNewTab } = useNavigation();
const p = useWorkspacePaths();
@@ -247,6 +260,11 @@ function ReadonlyLink({
if (isMentionHref(href)) {
const match = href.match(/^mention:\/\/(member|agent|issue|project|all)\/(.+)$/);
if (match?.[1] === "issue" && match[2]) {
// A bare identifier (from the autolink preprocessor) is carried as the id
// segment; a real mention carries a UUID. Dispatch on the id shape.
if (isIssueIdentifier(match[2])) {
return <AutolinkedIssueMentionLink identifier={match[2]} />;
}
const label =
typeof children === "string"
? children
@@ -433,7 +451,10 @@ export const ReadonlyContent = memo(function ReadonlyContent({
attachments,
}: ReadonlyContentProps) {
const processed = useMemo(
() => highlightToHtml(preprocessMarkdown(content)),
() =>
highlightToHtml(
preprocessMarkdown(content, { autolinkIssueIdentifiers: true }),
),
[content],
);
const wrapperRef = useRef<HTMLDivElement>(null);

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
preprocessIssueIdentifiers,
isIssueIdentifier,
} from "@multica/ui/markdown";
/**
* Pure detector for the Linear-style issue-identifier autolink. Lives in
* @multica/ui/markdown (no test runner there), exercised here where views'
* vitest can reach it.
*/
describe("preprocessIssueIdentifiers", () => {
it("rewrites a bare identifier into a canonical mention link", () => {
expect(preprocessIssueIdentifiers("Related to MUL-1745")).toBe(
"Related to [MUL-1745](mention://issue/MUL-1745)",
);
});
it("rewrites multiple identifiers in one string", () => {
expect(preprocessIssueIdentifiers("Created TES-1 and MUL-2")).toBe(
"Created [TES-1](mention://issue/TES-1) and [MUL-2](mention://issue/MUL-2)",
);
});
it("links an identifier at a sentence end (trailing dot + space)", () => {
expect(preprocessIssueIdentifiers("See MUL-1. Done.")).toBe(
"See [MUL-1](mention://issue/MUL-1). Done.",
);
});
it("links identifiers wrapped in prose punctuation", () => {
expect(preprocessIssueIdentifiers("(MUL-1) and [MUL-2]")).toContain(
"([MUL-1](mention://issue/MUL-1))",
);
});
// --- skip: code -------------------------------------------------------
it("skips identifiers inside inline code", () => {
expect(preprocessIssueIdentifiers("use `MUL-1` here")).toBe(
"use `MUL-1` here",
);
});
it("skips identifiers inside fenced code blocks", () => {
const input = "```\nMUL-1 in code\n```";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
// --- skip: existing links / mentions ----------------------------------
it("does not double-process an existing mention link", () => {
const input = "[MUL-1](mention://issue/00000000-0000-0000-0000-000000000001)";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
it("skips an identifier used as a markdown link label", () => {
const input = "[MUL-1](https://example.com/x)";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
// --- skip: urls / filenames / paths -----------------------------------
it("skips an identifier inside a URL", () => {
const input = "https://example.com/board/MUL-1";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
it("skips a filename token like ABC-123.ts", () => {
const input = "open ABC-123.ts now";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
it("skips a path segment like FOO-1/bar", () => {
const input = "path FOO-1/bar/baz";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
// --- non-matches ------------------------------------------------------
it("ignores lowercase tokens", () => {
const input = "some-word-1 and mul-1";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
it("ignores a token embedded in a larger word", () => {
const input = "XMUL-1A stays";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
it("returns input unchanged when no candidates exist", () => {
const input = "plain text with no identifiers";
expect(preprocessIssueIdentifiers(input)).toBe(input);
});
});
describe("isIssueIdentifier", () => {
it("accepts a bare identifier", () => {
expect(isIssueIdentifier("MUL-1745")).toBe(true);
expect(isIssueIdentifier("TES-1")).toBe(true);
});
it("rejects a UUID (so real mentions are not treated as identifiers)", () => {
expect(isIssueIdentifier("00000000-0000-0000-0000-000000000001")).toBe(
false,
);
});
it("rejects lowercase and malformed tokens", () => {
expect(isIssueIdentifier("mul-1")).toBe(false);
expect(isIssueIdentifier("MUL-")).toBe(false);
expect(isIssueIdentifier("MUL1")).toBe(false);
});
});

View File

@@ -1,4 +1,9 @@
import { preprocessLinks, preprocessMentionShortcodes, preprocessFileCards } from "@multica/ui/markdown";
import {
preprocessLinks,
preprocessMentionShortcodes,
preprocessFileCards,
preprocessIssueIdentifiers,
} from "@multica/ui/markdown";
import { configStore } from "@multica/core/config";
/**
@@ -8,21 +13,33 @@ import { configStore } from "@multica/core/config";
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
* was deleted in the April 2026 refactor.
*
* Three string→string transforms on raw Markdown:
* String→string transforms on raw Markdown:
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
* (old serialization format in database, migrated on read)
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
* 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for
* 2. (readonly only) Bare issue identifiers MUL-123 → [MUL-123](mention://issue/MUL-123)
* 3. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
* 4. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for
* fileCard node parsing
*
* Shared by the Tiptap editor and the read-only react-markdown renderer so both
* linkify identically.
* linkify identically. `autolinkIssueIdentifiers` is the one deliberate
* asymmetry: it is OPT-IN and MUST stay off for the editable Tiptap path, since
* rewriting a bare identifier there would create a mention node whose id is the
* identifier string (not a real UUID) and corrupt the saved markdown. Only the
* readonly renderer (which resolves the identifier to a UUID at render time)
* passes it.
*/
export function preprocessMarkdown(markdown: string): string {
export function preprocessMarkdown(
markdown: string,
opts?: { autolinkIssueIdentifiers?: boolean },
): string {
if (!markdown) return "";
const cdnDomain = configStore.getState().cdnDomain;
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
const step3 = preprocessFileCards(step2, cdnDomain);
return step3;
const step2 = opts?.autolinkIssueIdentifiers
? preprocessIssueIdentifiers(step1)
: step1;
const step3 = preprocessLinks(step2);
const step4 = preprocessFileCards(step3, cdnDomain);
return step4;
}

View File

@@ -3,3 +3,4 @@ export { useIssueReactions } from "./use-issue-reactions";
export { useIssueSubscribers } from "./use-issue-subscribers";
export { useIssueDetailScrollRestore } from "./use-issue-detail-scroll-restore";
export { useInPageFind } from "./use-in-page-find";
export { useResolveIssueIdentifier } from "./use-resolve-issue-identifier";

View File

@@ -0,0 +1,38 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { issueIdentifierOptions } from "@multica/core/issues/queries";
import { useCurrentWorkspace } from "@multica/core/paths";
import { isIssueIdentifier } from "@multica/ui/markdown";
import type { Issue } from "@multica/core/types";
/**
* Resolve a bare issue identifier ("MUL-123") to a real issue in the current
* workspace, or `null`. Backs the Linear-style autolink render path.
*
* Server state → TanStack Query (key includes wsId + identifier, so identical
* identifiers across many comments/messages share one request).
*
* The query is short-circuited (no network) when:
* - there is no current workspace, or
* - the token is not identifier-shaped, or
* - the identifier's prefix cannot match the workspace's `issue_prefix`.
*
* When the prefix is unknown (workspace list still loading) we fall through to
* the query and let the exact-match filter in `issueIdentifierOptions` decide.
*/
export function useResolveIssueIdentifier(identifier: string): Issue | null {
const workspace = useCurrentWorkspace();
const wsId = workspace?.id ?? "";
const prefix = workspace?.issue_prefix;
const prefixMatches =
!prefix ||
identifier.toUpperCase().startsWith(`${prefix.toUpperCase()}-`);
const { data } = useQuery({
...issueIdentifierOptions(wsId, identifier),
enabled: Boolean(wsId) && isIssueIdentifier(identifier) && prefixMatches,
});
return data ?? null;
}