mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
* 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>
141 lines
4.3 KiB
TypeScript
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";
|