mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ReadonlyContent — lightweight markdown renderer for readonly content display.
|
|
*
|
|
* Replaces <ContentEditor editable={false}> for comment cards and other
|
|
* read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror
|
|
* instance, eliminating EditorView, Plugin, and NodeView overhead.
|
|
*
|
|
* Visual parity with ContentEditor is achieved by:
|
|
* - Wrapping output in <div class="rich-text-editor readonly"> so the same
|
|
* styles/index.css rules apply to standard HTML tags
|
|
* - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify)
|
|
* - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight)
|
|
* so .hljs-* CSS rules from styles/code.css produce identical colors
|
|
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
|
*/
|
|
|
|
import { isValidElement, memo, useMemo, useRef, useState } from "react";
|
|
import ReactMarkdown, {
|
|
defaultUrlTransform,
|
|
type Components,
|
|
} from "react-markdown";
|
|
import type { ReactNode } from "react";
|
|
import rehypeKatex from "rehype-katex";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
import remarkMath from "remark-math";
|
|
import rehypeRaw from "rehype-raw";
|
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
import { toHtml } from "hast-util-to-html";
|
|
import { Check, Copy } from "lucide-react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { copyText } from "@multica/ui/lib/clipboard";
|
|
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
|
|
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, isIssueIdentifier } from "@multica/ui/markdown";
|
|
import { preprocessMarkdown } from "./utils/preprocess";
|
|
import { highlightToHtml } from "./utils/highlight-markdown";
|
|
import { MermaidDiagram } from "./mermaid-diagram";
|
|
import { HtmlBlockPreview } from "./html-block-preview";
|
|
import { AttachmentDownloadProvider } from "./attachment-download-context";
|
|
import { Attachment as AttachmentRenderer } from "./attachment";
|
|
import { highlightCode } from "./syntax-highlight";
|
|
import "katex/dist/katex.min.css";
|
|
import "./styles/index.css";
|
|
|
|
// Code fences that the `code` renderer returns as a non-<code> React element
|
|
// (Mermaid diagram, HTML preview iframe). The `pre` renderer below unwraps
|
|
// these so the default <pre><code> envelope doesn't clamp their styles.
|
|
// Anchored to whole class tokens so `language-htmlbars` / `language-mermaidx`
|
|
// don't accidentally match and lose their <pre> wrapper.
|
|
const PRE_UNWRAP_RE = /(^|\s)language-(html|mermaid)(\s|$)/;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sanitizeSchema = {
|
|
...defaultSchema,
|
|
// Allow <mark> (text highlight) — emitted by highlightToHtml from `==text==`.
|
|
// It carries no attributes, so only the tag name needs whitelisting.
|
|
tagNames: [...(defaultSchema.tagNames ?? []), "mark"],
|
|
protocols: {
|
|
...defaultSchema.protocols,
|
|
href: [...(defaultSchema.protocols?.href ?? []), "mention", "slash"],
|
|
// Permit inline data-URI images (QR codes, charts, base64 screenshots).
|
|
// The scheme gate only allows `data:` through here; attributes.img below
|
|
// narrows it to image/* so non-image data URIs are still rejected.
|
|
src: [...(defaultSchema.protocols?.src ?? []), "data"],
|
|
},
|
|
attributes: {
|
|
...defaultSchema.attributes,
|
|
div: [
|
|
...(defaultSchema.attributes?.div ?? []),
|
|
"dataType",
|
|
"dataHref",
|
|
"dataFilename",
|
|
],
|
|
code: [
|
|
...(defaultSchema.attributes?.code ?? []),
|
|
["className", /^language-/],
|
|
["className", /^math-/],
|
|
["className", /^hljs/],
|
|
],
|
|
img: [
|
|
// Drop the default plain `src` entry so the value allow-list below is the
|
|
// one findDefinition resolves — it returns the first match by name, so a
|
|
// bare `src` string would otherwise shadow (and disable) the allow-list.
|
|
...(defaultSchema.attributes?.img ?? []).filter(
|
|
(attr) => (typeof attr === "string" ? attr : attr[0]) !== "src",
|
|
),
|
|
"alt",
|
|
// Allow inline data:image/* URIs while leaving every other src form
|
|
// (http/https/site-relative) exactly as before: the negative lookahead
|
|
// keeps all non-data values, and data: is narrowed to images only.
|
|
["src", /^data:image\//i, /^(?!data:)/i],
|
|
],
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL transform — allow mention:// protocol through react-markdown's sanitizer
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function urlTransform(url: string): string {
|
|
if (url.startsWith("mention://")) return url;
|
|
if (url.startsWith("slash://skill/")) return url;
|
|
// Allow inline data:image/* URIs — defaultUrlTransform strips every data: URL
|
|
// to '', which would blank the src even after rehype-sanitize keeps it. Kept
|
|
// in sync with the image/* narrowing in sanitizeSchema (protocols.src +
|
|
// attributes.img) so both gates agree on what a valid inline image is.
|
|
if (/^data:image\//i.test(url)) return url;
|
|
return defaultUrlTransform(url);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom react-markdown components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Issue mention chip. Navigation — plain click, modifier click, and the
|
|
* "open issue links in new tab" preference — is owned by the AppLink inside
|
|
* IssueMentionCard; the wrapper only shields surrounding click handlers
|
|
* (e.g. collapsed-comment expanders) from mention clicks.
|
|
*/
|
|
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
|
|
return (
|
|
<span className="inline align-middle" onClick={(e) => e.stopPropagation()}>
|
|
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
const path = p.projectDetail(projectId);
|
|
return (
|
|
<span
|
|
className="inline align-middle"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
if (openInNewTab) {
|
|
openInNewTab(path, label);
|
|
}
|
|
return;
|
|
}
|
|
push(path);
|
|
}}
|
|
>
|
|
<ProjectChip projectId={projectId} fallbackLabel={label} className="cursor-pointer hover:bg-accent transition-colors" />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function getTextContent(node: ReactNode): string {
|
|
if (node == null || typeof node === "boolean") return "";
|
|
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
if (Array.isArray(node)) return node.map(getTextContent).join("");
|
|
if (isValidElement(node)) {
|
|
const props = node.props as { children?: ReactNode };
|
|
return getTextContent(props.children);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function ReadonlyCodeBlock({
|
|
children,
|
|
language,
|
|
}: {
|
|
children: ReactNode;
|
|
language?: string;
|
|
}) {
|
|
const { t } = useT("editor");
|
|
const [copied, setCopied] = useState(false);
|
|
const code = useMemo(
|
|
() => getTextContent(children).replace(/\n$/, ""),
|
|
[children],
|
|
);
|
|
const copyLabel = t(($) => $.code_block.copy_code) || "Copy code";
|
|
|
|
const handleCopy = async () => {
|
|
if (!code) return;
|
|
if (await copyText(code)) {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="code-block-wrapper group/code relative my-3">
|
|
<div className="absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100">
|
|
{/* Same hover chrome as the editable code block's header
|
|
(code-block-view.tsx): language label + copy. */}
|
|
{language && (
|
|
<span className="text-xs text-muted-foreground select-none">
|
|
{language}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
title={copyLabel}
|
|
aria-label={copyLabel}
|
|
>
|
|
{copied ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
{/* No extra right padding: `.rich-text-editor pre` outranks utility
|
|
padding classes anyway, and the editable NodeView uses the same
|
|
1rem — keeping them identical keeps line wrapping identical. */}
|
|
<pre className="!m-0">{children}</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Named component so it can call useWorkspaceSlug() — arrow function inlined
|
|
// inside `components` below would still work, but extracting it keeps the
|
|
// hook usage explicit and avoids hook-in-object-literal surprises.
|
|
function ReadonlyLink({
|
|
href,
|
|
children,
|
|
}: {
|
|
href?: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const slug = useWorkspaceSlug();
|
|
|
|
if (href?.startsWith("slash://skill/")) {
|
|
return <span className="slash-command">{children}</span>;
|
|
}
|
|
|
|
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
|
|
: Array.isArray(children)
|
|
? children.join("")
|
|
: undefined;
|
|
return <IssueMentionLink issueId={match[2]} label={label} />;
|
|
}
|
|
if (match?.[1] === "project" && match[2]) {
|
|
const label =
|
|
typeof children === "string"
|
|
? children
|
|
: Array.isArray(children)
|
|
? children.join("")
|
|
: undefined;
|
|
return <ProjectMentionLink projectId={match[2]} label={label} />;
|
|
}
|
|
// Member / agent / all mentions
|
|
return <span className="mention">{children}</span>;
|
|
}
|
|
|
|
// Regular links — open directly on click
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (href) openLink(href, slug);
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function buildComponents(): Partial<Components> {
|
|
return {
|
|
// Links — route mention:// to mention components, others show preview card
|
|
a: ReadonlyLink,
|
|
|
|
// Images — unified through <Attachment>. The resolver context provided
|
|
// by AttachmentDownloadProvider (mounted in ReadonlyContent below) turns
|
|
// a CDN URL into a full record when possible; external URLs render as
|
|
// plain images with lightbox-via-preview-modal. forceKind is mandatory
|
|
// here because markdown `![]()` carries no content-type and alt is
|
|
// commonly empty or descriptive — without it images fall through to
|
|
// the file-card chrome.
|
|
img: ({ src, alt }) => (
|
|
<AttachmentRenderer
|
|
attachment={{
|
|
kind: "url",
|
|
url: typeof src === "string" ? src : "",
|
|
filename: alt ?? "",
|
|
forceKind: "image",
|
|
}}
|
|
/>
|
|
),
|
|
|
|
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
|
|
div: ({ node, children, ...props }) => {
|
|
const dataType = node?.properties?.dataType as string | undefined;
|
|
if (dataType === "fileCard") {
|
|
const rawHref = (node?.properties?.dataHref as string) || "";
|
|
const href = isAllowedFileCardHref(rawHref) ? rawHref : "";
|
|
const filename = (node?.properties?.dataFilename as string) || "";
|
|
return (
|
|
<AttachmentRenderer
|
|
attachment={{ kind: "url", url: href, filename }}
|
|
/>
|
|
);
|
|
}
|
|
return <div {...props}>{children}</div>;
|
|
},
|
|
|
|
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
|
|
table: ({ children }) => (
|
|
<div className="tableWrapper">
|
|
<table>{children}</table>
|
|
</div>
|
|
),
|
|
|
|
// Code — lowlight highlighting for blocks, plain render for inline
|
|
code: ({ className, children, node, ...props }) => {
|
|
const lang = /language-(\w+)/.exec(className || "")?.[1];
|
|
const isBlock =
|
|
node?.position &&
|
|
node.position.start.line !== node.position.end.line;
|
|
|
|
if (isBlock && lang === "mermaid") {
|
|
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
|
|
}
|
|
if (isBlock && lang === "html") {
|
|
// Like Mermaid, return the React element directly here and rely on
|
|
// the `pre` renderer below to unwrap it — react-markdown otherwise
|
|
// wraps `code` children in a `<pre>` whose monospace + overflow
|
|
// styles would clamp the preview iframe.
|
|
return <HtmlBlockPreview html={String(children).replace(/\n$/, "")} />;
|
|
}
|
|
|
|
if (!isBlock && !lang) {
|
|
// Inline code — CSS handles styling via .rich-text-editor code
|
|
return <code {...props}>{children}</code>;
|
|
}
|
|
|
|
// Block code — highlight with lowlight, output hljs classes
|
|
const code = String(children).replace(/\n$/, "");
|
|
try {
|
|
const tree = highlightCode(code, lang);
|
|
const html = toHtml(tree);
|
|
if (html) {
|
|
return (
|
|
<code
|
|
className={cn("hljs", lang && `language-${lang}`)}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
);
|
|
}
|
|
} catch {
|
|
// fall through to plain render
|
|
}
|
|
return (
|
|
<code className={cn("hljs", className)} {...props}>
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
|
|
// Pre — wrap regular code fences with copy chrome.
|
|
// Special-case Mermaid / HtmlBlockPreview returned from the `code`
|
|
// renderer above so the outer `<pre>` does not wrap them — this is the
|
|
// standard two-layer pattern used to escape react-markdown's default
|
|
// `<pre><code>` envelope.
|
|
pre: ({ children }) => {
|
|
// react-markdown calls `pre` BEFORE invoking the `code` renderer —
|
|
// `children` is the unrendered `<code>` element from the AST. So we
|
|
// identify "this block was meant to be unwrapped" by inspecting the
|
|
// child's className (`language-mermaid`, `language-html`), not by
|
|
// checking `children.type === MermaidDiagram`, which never matches.
|
|
//
|
|
// Match by exact class token: a substring `includes("language-html")`
|
|
// would also fire on neighboring languages like `language-htmlbars`
|
|
// and silently strip their <pre> wrapper.
|
|
let language: string | undefined;
|
|
if (isValidElement(children)) {
|
|
const childProps = children.props as { className?: string };
|
|
if (PRE_UNWRAP_RE.test(childProps.className ?? "")) {
|
|
return <>{children}</>;
|
|
}
|
|
language = /language-(\w+)/.exec(childProps.className ?? "")?.[1];
|
|
}
|
|
return <ReadonlyCodeBlock language={language}>{children}</ReadonlyCodeBlock>;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ReadonlyContentProps {
|
|
content: string;
|
|
className?: string;
|
|
/**
|
|
* Attachments associated with the surrounding entity (comment / issue
|
|
* body). When the markdown contains an inline `<img>` or file card whose
|
|
* URL matches one of these attachments, the download button re-signs the
|
|
* URL at click time via `useDownloadAttachment` instead of opening the
|
|
* potentially stale link embedded in the markdown.
|
|
*
|
|
* Callers SHOULD pass a stable reference (e.g. the field on a memoized
|
|
* timeline entry); a fresh array on every parent render busts the memo.
|
|
*/
|
|
attachments?: Attachment[];
|
|
}
|
|
|
|
// Memoized so a long timeline of comments (Inbox + IssueDetail) does not
|
|
// re-run the full react-markdown + rehype-* + lowlight pipeline on every
|
|
// parent re-render. Props are `content`/`className`/`attachments`, all
|
|
// shallow-comparable; stability is the caller's responsibility for the
|
|
// array.
|
|
export const ReadonlyContent = memo(function ReadonlyContent({
|
|
content,
|
|
className,
|
|
attachments,
|
|
}: ReadonlyContentProps) {
|
|
const processed = useMemo(
|
|
() =>
|
|
highlightToHtml(
|
|
preprocessMarkdown(content, { autolinkIssueIdentifiers: true }),
|
|
),
|
|
[content],
|
|
);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const hover = useLinkHover(wrapperRef);
|
|
|
|
// Components map is now static — all attachment-aware logic lives in
|
|
// <Attachment>, which reads the surrounding AttachmentDownloadProvider.
|
|
const components = useMemo(() => buildComponents(), []);
|
|
|
|
// Memoize the whole react-markdown subtree on its only real inputs
|
|
// (`processed` + `components`). Unrelated parent re-renders (e.g. a sibling
|
|
// agent task streaming over WebSocket fires one every ~100ms) would otherwise
|
|
// re-run react-markdown, which hands `<code>` a fresh `dangerouslySetInnerHTML`
|
|
// object each time; React then rewrites the highlighted innerHTML even though
|
|
// the HTML string is byte-identical, tearing down and rebuilding every hljs
|
|
// <span> — which collapses any active text selection inside a code block
|
|
// (MUL-3621). A stable element reference lets React bail out of the subtree.
|
|
const markdown = useMemo(
|
|
() => (
|
|
<ReactMarkdown
|
|
remarkPlugins={[
|
|
[remarkMath, { singleDollarTextMath: false }],
|
|
remarkBreaks,
|
|
[remarkGfm, { singleTilde: false }],
|
|
]}
|
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
|
urlTransform={urlTransform}
|
|
components={components}
|
|
>
|
|
{processed}
|
|
</ReactMarkdown>
|
|
),
|
|
[processed, components],
|
|
);
|
|
|
|
return (
|
|
<AttachmentDownloadProvider attachments={attachments}>
|
|
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
|
{markdown}
|
|
<LinkHoverCard {...hover} />
|
|
</div>
|
|
</AttachmentDownloadProvider>
|
|
);
|
|
});
|