mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* refactor(markdown): single canonical sanitize schema for both renderers (MUL-4922) Phase 1 of the RichContent convergence: collapse the duplicated security base shared by the two product-level Markdown chains. Chat (packages/ui/markdown/Markdown.tsx) and Issue/Comment (packages/views/editor/readonly-content.tsx) each carried a verbatim fork of the rehype-sanitize schema and urlTransform, and the forks had already drifted: readonly whitelisted <mark> for `==highlight==`, chat did not. A security-relevant allow-list maintained in two places means every future XSS fix has to land twice, and missing one is a hole — this is the hardest reason for the sweep, ahead of the user-visible feature drift. - Extract markdownSanitizeSchema + markdownUrlTransform into packages/ui/markdown/sanitize.ts and export from the package index. Both chains now import the single copy; no local forks remain. - The canonical schema is the union, so chat gains the <mark> tag name. This is the one intentional behavior delta: <mark> is inert and admits no attributes, and chat needs it anyway once ==highlight== converges. - Annotate the schema as rehype-sanitize's Options: exporting it makes the previously-inferred hast-util-sanitize type unnameable across packages. Adds a cross-surface contract test that runs one set of security fixtures (script, event handlers, javascript: href, data:image vs data:text/html, mark, slash://) through BOTH surfaces and asserts identical outcomes — the mechanism that stops a third fork from growing back. Code-block rendering is deliberately not asserted cross-surface yet: chat highlights with Shiki, readonly with lowlight, so emitted class tokens still differ. Converging them is the RichCodeBlock phase and needs the highlight-engine decision first; only the schema-level allow-list is shared here. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), pnpm vitest run in packages/views (228 files, 2665 tests, all passing). Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(rich-content): one RichContent renderer for Chat and Issue/Comment (MUL-4922) Phase 2+3 of the convergence. Chat now renders agent output through the same product renderer as Issue descriptions and Comments, so a ```mermaid fence an agent emits is a diagram in Chat — not a dead code block. Before this there were two product Markdown chains. Issue/Comment had Mermaid, HTML preview, lowlight code, product Mention/Attachment/LinkHover; Chat went through a generic renderer with no Mermaid/HTML dispatcher at all. Chat is where agents emit the most diagrams, so the gap was most visible exactly where it mattered. Architecture - packages/views/rich-content/ holds the ONE product renderer: a single ReactMarkdown pipeline, one sanitize config, one components map, one fenced -code dispatcher. Public API is content/attachments/density/phase — no `surface` prop, no renderMention override, no custom code-renderer hook, because each is a door a per-surface fork walks back through. - `density` is CSS only; `phase` is lifecycle only. Neither switches parser, plugins or the semantic DOM, so all surfaces emit the same blocks. - ReadonlyContent is now a ~40-line compatibility wrapper (was 501 lines); its consumers are untouched. views/common/markdown.tsx is deleted. - Leaf components (Mermaid, HTML preview, lowlight code) stay in editor/ and are imported by direct path, so Tiptap keeps reusing them and Chat does not pull in the editor's Tiptap graph. Moving them is a later mechanical commit. Streaming fence gate Rich blocks upgrade only when the fence is CLOSED, judged by a real CommonMark parse (mdast) rather than a `startsWith("```")` scan. A half-streamed fence renders as source, so Mermaid never parses a partial diagram and no iframe is created for HTML still arriving. Closedness — not `phase` — is the gate, so a settled-but-malformed fence stays source too. The gate returns offsets only; it never renders, because a gate that rendered would be the second renderer this change exists to delete. Verified that source offsets survive rehype-raw + sanitize + katex before relying on them. Live -> persisted row identity The live timeline moved out of Virtuoso's Footer into a real row keyed `task:<taskId>`, and persisted assistant rows key on the same task instead of `message.id`. Completion is now an in-place data swap through one AssistantMessage component, so an already-rendered diagram or iframe stays mounted and is not re-run. The process fold previously relied on that remount to re-collapse, so the collapse is now expressed directly. Notes - Chat code blocks move from Shiki to lowlight (Howard's ratified choice), matching Tiptap/Readonly and the existing hljs CSS. Ligature suppression carries over via code.css instead of utility classes. - Chat message tests now stub react-virtuoso: real Virtuoso renders its Footer but no data rows under jsdom's zero-height viewport, and the live timeline is a row now. Tests: five-surface Mermaid parity (Chat user / live / persisted, Issue description, Comment), streaming open->closed->settled with mount counting, live->persisted no-remount, htmlbars/mermaidx not misdispatched, sandbox and data-URI security regressions, CommonMark fence edge cases (shorter closer, tilde, indented, nested, in-list), and source-level import boundary guards. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 240 files / 2743 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): drop dangling export, add export guard + lazy rich blocks (MUL-4922) Two review follow-ups. 1. Dangling package export `packages/views` still exported "./common/markdown" after the file was deleted in the RichContent convergence. TypeScript resolves against the source tree so typecheck and unit tests both passed; the subpath would only fail when a consuming app bundled it. No consumer imports it, so this was latent rather than broken in practice. Removes the entry and adds packages/views/rich-content/package-exports.test.ts, which walks every workspace package.json and asserts each export target exists (wildcards checked by directory prefix). Scoped repo-wide because the failure mode belongs to package.json, not to this package. Verified the guard actually fails by re-adding the dangling entry before committing. 2. Near-viewport lazy shell for Mermaid / HTML Implements the deferred performance contract rather than requesting an exemption. LazyRichBlock defers each rich leaf until it is within 800px of the viewport, then latches: once mounted it is never unmounted, so scrolling past does not re-run Mermaid, rebuild the sandboxed iframe, or discard the viewer's pan/zoom state. The stable-size requirement is handled by reserving the block's expected height before AND after mount, so a block never measures 0px off-screen and then jumps — the churn that makes a virtualized list mis-estimate item sizes. The reservation is not a local guess: reservedMermaidHeightPx() reuses the existing session layout cache (real height on a cache hit, else the documented 280px skeleton) and HTML_BLOCK_PREVIEW_HEIGHT_PX is the pixel twin of the preview iframe's fixed h-[480px]. Both are exported from the leaves so there is one source of truth per block type. Environments without IntersectionObserver (jsdom, SSR) mount eagerly, which is today's behaviour; rendering nothing would not be. Verified in real Chromium (jsdom has no IntersectionObserver, so unit tests only exercise the eager fallback): - Non-virtualized Issue/Comment with 25 diagrams: 3 mounted, 22 deferred on load; after scrolling, mounted grows 3 -> 6 and never drops, confirming the latch. This is where the win is real. - Chat gains less: Virtuoso already caps the DOM to ~4 rows, so only 1 of 4 shells was deferred. Stated rather than overclaimed. - Live -> persisted handoff re-checked with the shell in place: scrollTop 220 -> 220 (delta 0), the tagged diagram DOM node survived, and the run reached the persisted state. Contract 1 is not regressed. Tests: 6 lazy-shell cases (defer, mount-on-approach, latch/no-unmount, equal reserved height before and after mount, root margin exceeding the chat list's own overscan, no-IntersectionObserver fallback) plus 3 export-guard cases. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 242 files / 2752 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): SSR-deterministic lazy state, mention a11y, drop type casts (MUL-4922) Three review blockers. 1. Hydration mismatch in the lazy shell LazyRichBlock derived its initial `mounted` state from feature detection inside useState. On the server (no window) that resolved true and rendered the whole Mermaid/HTML subtree; in the browser (IntersectionObserver present) the hydration pass resolved false and rendered a placeholder — different markup for the same component, and an SSR path that silently bypassed the lazy gate. `"use client"` does not opt a component out of Next's server render, so this was reachable. Initial state is now unconditionally false on both sides. The eager fallback for environments without IntersectionObserver moved into the effect, which never runs on the server, so the first committed frame is identical everywhere and the latch is unchanged. The first version of the SSR test passed against the buggy component: jsdom always provides `window`, so server and client took the same detection branch and the mismatch was unobservable. The suite now removes IntersectionObserver for the duration of renderToString to reproduce the real asymmetry. Verified by reinstating the bug: 2 of the 3 SSR tests fail and React raises its own "Hydration failed" error. 2. Project mention lost keyboard access The unified renderer inherited the readonly surface's `<span onClick>` around ProjectChip, while Chat had previously used AppLink — so converging the surfaces regressed Chat from a focusable anchor to a mouse-only span. A span and an anchor are visually identical and behave the same under a mouse, which is why only an assertion on the emitted element catches it. Now rendered through AppLink, which also owns plain-click, modifier-click and the desktop new-tab adapter, so none of that is reimplemented. The wrapper keeps only stopPropagation, matching IssueMentionCard. Adds project-mention-a11y.test.tsx using the REAL AppLink and NavigationProvider — mocking AppLink to emit an anchor would assert the mock. Covers anchor + href, chip's nearest interactive ancestor, Tab focus, Enter activation, click, and modifier-click labelling. All 6 fail on the old span. 3. Type suppressions in the production renderer `as never` on the plugin lists and `as NonNullable<Components[...]>` on the code/pre overrides silenced real type errors, against the repo's strict-TS rule. Replaced with react-markdown's own types: RichCode/RichPre now derive their props from `ComponentPropsWithoutRef<tag> & ExtraProps`, and the plugin lists use `satisfies NonNullable<Options["remarkPlugins" | "rehypePlugins"]>` so a bad plugin tuple still fails to compile. Also removed the remaining casts this exposed: hast property reads went through `as string` even though hast property values are a union, so a non-string `data-*` attribute would have been typed as a lie — replaced with a runtime-narrowing helper. `toHtml()` already returns string; its cast was redundant. Node position reads are narrowed in one helper. No cast or ts-ignore remains in production rich-content. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 243 files / 2780 tests all passing. Co-authored-by: multica-agent <github@multica.ai> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rich-content): cached-height hydration, scroll-root + recycle latch, CDN reactivity (MUL-4922) Three review blockers. 1. Cached reserved height still reached the first frame The previous fix made `mounted` deterministic but left the *height* reading sessionStorage during render: RichFenceBlock called reservedMermaidHeightPx() inline. Server has no sessionStorage so it emitted 280px, while a browser with a warm cache emitted the real height — a differing style="min-height:…" on the frame React hydrates, which React reports as an attribute mismatch and does not repair. Same bug class as the one already fixed, one layer up. RichFenceBlock now splits into Mermaid/HTML components (so the height hook is never conditional) and reserves the skeleton default on the first frame, adopting the cached height in an effect. Zero-shift on a warm cache is kept; only the read moves after hydration. The earlier SSR suite passed a fixed reservedHeightPx straight to the lazy shell, bypassing this path — it could not have caught this. New tests drive the real RichFenceBlock with a real prefilled sessionStorage entry, and simulate the server by removing sessionStorage for the renderToString call (jsdom provides one, so without that the "server" takes the browser branch and the mismatch is invisible). 2. Wrong observer root, and a latch that died with the row The IntersectionObserver set only rootMargin, so it clipped against the viewport — but Chat scrolls inside its own element (Virtuoso customScrollParent). Expanding the viewport box says nothing about a nested scroller, so Chat blocks only loaded once already visible and the preload was effectively dead there. Surfaces now publish their scroll container through RichContentScrollRootProvider and the observer uses it as `root`; page-scrolled surfaces keep the viewport root. Separately, the mount latch was component state, so Virtuoso recycling a row discarded it and scrolling back re-ran Mermaid, rebuilt the iframe and dropped viewer pan/zoom — the per-pass cost the shell exists to prevent. The latch moved to a module-level registry keyed by a hash of the content, bounded at 500 entries with oldest-first eviction. Restore happens in a layout effect (before paint, so no placeholder flash) and never in initial state, keeping the server/client first frame identical. Scope was not narrowed. 3. CDN config arriving late never reprocessed content preprocessMarkdown read configStore.getState().cdnDomain imperatively and RichContent's memo depended only on `content`, so content rendered before the async config landed kept legacy CDN links as plain anchors permanently. cdnDomain is now an explicit parameter: RichContent subscribes via useConfigStore and puts it in the memo deps, while the Tiptap editor — which genuinely preprocesses once at load — reads the store at its own call sites. No fallback branch. Each fix was verified by reinstating its bug and confirming the new tests fail (2/5, 2/14 and 1/3 respectively) rather than trusting a green run. Also fixes a false positive in the boundary guard: the Tiptap check read raw file text instead of using the suite's stripComments helper, so a doc comment mentioning RichContent counted as a violation. Verified: pnpm typecheck (6 workspaces), pnpm lint (0 errors), packages/views vitest 245 files / 2793 tests all passing. 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>