Commit Graph

6 Commits

Author SHA1 Message Date
Naiyuan Qing
5a11232c47 feat(rich-content): unify Chat and Issue/Comment on one RichContent renderer (MUL-4922) (#5578)
* 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>
2026-07-21 11:40:00 +08:00
Naiyuan Qing
ea03912baf perf(desktop,issues): single-router tab sessions (MUL-4741 Phase 2) + trace-driven surface mount/render overhaul (MUL-4474/4750 reland) (#5403)
* 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>
2026-07-15 16:30:51 +08:00
Bohan Jiang
d6540a1869 fix(clipboard): support copy over http:// via execCommand fallback (#3810)
navigator.clipboard is only exposed in a secure context (https or
localhost). On self-hosted instances served over plain http:// it is
undefined, so every copy / "copy all" / export button silently failed and
left the clipboard empty (GitHub #3781).

Add a shared copyText(text): Promise<boolean> helper in
@multica/ui/lib/clipboard that prefers the async Clipboard API and falls
back to a hidden <textarea> + document.execCommand('copy') for non-secure
contexts. Migrate all direct navigator.clipboard.writeText call sites
(code blocks, agent transcript copy-all, token / webhook / issue-link
copy, etc.) to it, gating success side-effects on the returned boolean,
and remove the now-redundant copyMarkdown wrapper. Secure-context users
keep the native path unchanged.

MUL-3068

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:55:23 +08:00
Naiyuan Qing
fbd965e5bf feat(onboarding): v3 — thin server, frontend-orchestrated welcome (#3008)
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal

Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:

  - Who I am (built-in workspace assistant, not onboarding-only)
  - What Multica is + docs/source/issues URLs as knowledge sources
  - What I can do (CLI = manifest, `multica --help` is the source of truth)
  - Tone (concise, like a colleague, match user's language)

Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.

Side effects required to make `onboarded_at == null` an honest signal:

  - CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
    The "member exists ⟹ onboarded_at != null" invariant is intentionally
    broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
    this — comments updated to reflect the new contract.
  - AcceptInvitation still marks (invitee skips the modal in someone
    else's workspace). Code comment added warning future removers.
  - resolvePostAuthDestination flips to workspace-presence-first: a user
    with a workspace lands in it regardless of `onboarded_at`, so the
    modal can pick up an interrupted setup on relogin.

Other backend changes:
  - `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
  - `onboardingAssistantInstructions` rewritten to the 3-section identity
  - `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
    cap, empty-falls-back-to onboardingIssueDescription)

Frontend changes:
  - Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
    persisted step)
  - `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
  - `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
    bootstrap, `onboarded_at` stays NULL so the modal fires
  - Runtime step next-button copy → "Start exploring" / "开始探索"
  - New `packages/views/workspace/onboarding-helper-modal.tsx`:
    Base UI Dialog, dismissible=false, three localized cards, mutation
    invalidates agents + issues queries then navigates to the seeded issue
  - Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
    `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`

Tests:
  - Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
    TestCreateWorkspace_DoesNotMarkOnboarded
  - Frontend: onboarding-helper-modal.test.tsx covers all four gating
    conditions, three-card behavior, mutation pending state, and the
    "no close button" invariant

Compatibility:
  - Already-onboarded users: zero impact (modal can't fire)
  - Invitees: AcceptInvitation still marks → modal can't fire
  - Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
  - Old desktop / web clients: legacy teammate-step path keeps working
    (bootstrap accepts missing starter_prompt) — the new modal only fires
    on the new frontend bundle
  - Avatar SVG kept (asterisk variant) — no migration of existing Helper
    agents, only newly-created Helpers pick up the new instructions/description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open

On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).

Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.

Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(onboarding): add v2 refactor plan

Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)

Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.

PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.

Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(server): collapse onboarding side effects to service layer

Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:

  - MarkUserOnboarded + claim starter_content_state +
    optional install-runtime fallback seed: was inline in
    BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
    AcceptInvitation, and CompleteOnboarding.
  - install-runtime issue seeding: was inline in CreateWorkspace and
    AcceptInvitation as a "no runtime yet" fallback.

After this refactor:
  - MarkUserOnboarded is called from exactly one place (the service).
  - install-runtime issue is seeded from exactly one place (the service).
  - CreateWorkspace deliberately does not seed — the new
    /ensure-onboarding-content endpoint (also added here) lets the
    workspace-entry init component request the seed on first mount, so
    workspaces created but never opened don't accumulate stale issues.
  - The PatchOnboarding handler now accepts the new runtime_id /
    runtime_skipped fields and rejects (uuid, skipped=true) up front.
  - UserResponse exposes the two new persisted fields so the frontend
    can read them off `me` without an extra round-trip.

Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.

Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core): API + types for persisted onboarding runtime choice

User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.

Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal

Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:

  branch 0  me.onboarded_at != null         → ensure install-runtime issue
                                              fallback, render nothing
  branch 1  me.onboarding_runtime_skipped   → SkipBootstrapping component:
                                              loading veil → bootstrap →
                                              navigate. On failure shows
                                              a Retry UI instead of
                                              silently freezing the veil
  branch 2  me.onboarding_runtime_id        → render Modal with the
                                              runtime id from `me` (no
                                              internal list query)
  branch 3  (none of the above)             → useEffect navigate back to
                                              /onboarding so the user
                                              walks Step 3 again

The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.

Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): Step 3 records user choice instead of calling bootstrap

handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.

PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.

The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): v3 — thin server, frontend-orchestrated welcome

Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.

V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
  park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
  - runtime path: find-or-create Multica Helper via generic createAgent
    with bilingual instructions from `templates/helper-instructions.ts`,
    blocking modal with 3 starter cards, pick -> createIssue + navigate
  - skip path: provision install-runtime (in_progress) -> agent-guide
    (todo, body embeds install-runtime mention chip) -> follow-up comment
    on install-runtime mentioning agent-guide; then pop celebration
    modal with 🎉 emoji pop animation, 2 read-only preview cards, single
    [Got it] CTA that navigates to install-runtime

Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
  columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
  directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
  deprecation shim in onboarding_shim.go for desktop < v3 during the
  rollout window — handlers inlined to qtx.* calls, no service layer

Localization
- Persisted strings (issue titles/bodies, Helper instructions/
  description, comment prefix) live as TS const `{en, zh}` maps in
  `packages/views/onboarding/templates/` — i18n bundle staleness can no
  longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
  `packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
  null for new users until they pick a preference)

Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
  `postCommentDeduped`) so React StrictMode double-mount can't fire two
  parallel API calls that the server would then 409

Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety

Welcome modal polish (the post-Step-3 surface this branch already
introduced):

Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
  fade. New copy: "Hi, welcome to Multica / I'm your first Agent
  assistant" + capability hint sentence so users discover assignment +
  chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
  border-primary + ring selection pattern used by compact-runtime-row;
  bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
  Helper to paste an HTML welcome page into the issue comment — works
  on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
  "All set!" + "Sit tight  — your {agentName} is on it" + inbox/chat
  hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
  HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
  bundle being loaded); subtitle stays in onboarding.json.

Skip path
- Body restructured into three independent ```md blocks (Name /
  Description / Instructions) so each picks up the markdown renderer's
  per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
  Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
  [identifier](mention://issue/uuid) so it renders as the styled
  IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.

Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
  useWelcomeStore.reset() so user B logging into the same browser
  doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
  currentWorkspace.id === signal.workspaceId — prevents firing the
  modal in workspace B when the signal was parked for workspace A
  (desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
  pendingIssueSeed, pendingCommentSeed) for the three API calls so
  React 18+ StrictMode dev double-mount can't race-create duplicates.

Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
  read i18n.language (not me.language, which is null for new users
  who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
  avatar (kept the bouncy variant for the skip 🎉 hero where the
  celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
  one only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views): i18n hardcoded "Close" in welcome FullScreenError

CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:00:26 +08:00
Jiayuan Zhang
044f7f0cc6 feat(editor): bump HTML iframe preview default height to 480px (MUL-2419) (#2842)
320px was too cramped for typical rendered HTML (charts, dashboards,
formatted documents). Matches the existing HTML attachment preview
height for visual consistency across both iframe surfaces.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:15:03 +02:00
Naiyuan Qing
ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

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

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:23:40 +08:00