Files
multica/packages/views/common/markdown.tsx
Naiyuan Qing 3f02083fec 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>
2026-07-09 21:46:33 +08:00

141 lines
4.3 KiB
TypeScript

"use client";
import * as React from "react";
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 {
Attachment as AttachmentRenderer,
AttachmentDownloadProvider,
} from "../editor";
export type { RenderMode };
export interface MarkdownProps extends MarkdownBaseProps {
/**
* Attachments associated with the surrounding entity (chat message, skill
* file). When passed, the renderer resolves inline image / file-card URLs
* to full attachment records via AttachmentDownloadProvider, unlocking the
* unified hover toolbar / lightbox / preview-modal behavior used in
* editor surfaces.
*/
attachments?: AttachmentRecord[];
}
/**
* Default renderMention that delegates to entity chips for issue/project mentions
* and renders a styled span for other mention types.
*/
function ProjectMentionCard({ projectId }: { projectId: string }): React.ReactNode {
const p = useWorkspacePaths();
return (
<AppLink href={p.projectDetail(projectId)} className="project-mention not-prose inline-flex">
<ProjectChip
projectId={projectId}
className="cursor-pointer hover:bg-accent transition-colors"
/>
</AppLink>
);
}
/**
* 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,
}: {
type: string;
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") {
return <ProjectMentionCard projectId={id} />;
}
return null;
}
function renderImage({ src, alt }: { src: string; alt: string }): React.ReactNode {
return (
<AttachmentRenderer
attachment={{
kind: "url",
url: src,
filename: alt,
// chat / skill markdown `![]()` is structurally an image. Without
// forceKind, empty/descriptive alt strings would route to the
// file-card chrome via getPreviewKind autodetect.
forceKind: "image",
}}
/>
);
}
function renderFileCard({
href,
filename,
}: {
href: string;
filename: string;
}): React.ReactNode {
return (
<AttachmentRenderer
attachment={{ kind: "url", url: href, filename }}
/>
);
}
/**
* App-level Markdown wrapper. Injects:
* - entity chips for issue/project mentions
* - cdnDomain from the config store (drives fileCard preprocessing)
* - unified <Attachment> as the image / file-card renderer
* - AttachmentDownloadProvider so url → record resolution works inside
* the injected <Attachment> components
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
const cdnDomain = useConfigStore((s) => s.cdnDomain);
const { attachments, ...rest } = props;
return (
<AttachmentDownloadProvider attachments={attachments}>
<MarkdownBase
renderMention={defaultRenderMention}
renderImage={renderImage}
renderFileCard={renderFileCard}
cdnDomain={cdnDomain}
autolinkIssueIdentifiers
{...rest}
/>
</AttachmentDownloadProvider>
);
}
export const MemoizedMarkdown = React.memo(Markdown);
MemoizedMarkdown.displayName = "MemoizedMarkdown";