Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Give the page back the wheel over a Mermaid diagram

Status: implemented
Translation: pending

## Abstract

Streamdown wraps every rendered Mermaid diagram in a pan/zoom canvas whose
non-passive `wheel` listener calls `preventDefault()`, so scrolling a conversation
stopped dead and zoomed the diagram whenever the pointer happened to rest over
one; `controls.mermaid.panZoom: false` hides that canvas's buttons but not its
listener. A diagram in a message is now a still preview: `markdown-renderer.tsx`
takes the wheel in the capture phase and re-dispatches an uncancelable copy so the
conversation's own wheel listeners still see the gesture, and `!important`
overrides return `touch-action` and the cursor to the page. Canvas behaviour moved
to the full-screen viewer, where a trackpad pinch (ctrl-modified wheel) zooms
around the pointer and a held mouse button drags. Touch panning stays with the
browser's scrolling, which is why two-finger pinch on a touch screen is
deliberately absent rather than partly implemented.

## Decision

Two surfaces, one rule each. A diagram in a message opens the viewer and does
nothing else; the viewer is the only canvas.

Streamdown's canvas is neutralized from outside rather than removed, because the
package offers no way to turn it off:

- `wheel` is intercepted on the markdown root in the capture phase, above the
canvas element that carries the listener. The interceptor never calls
`preventDefault()` — the browser's own scrolling is the behaviour being restored.
- `stopPropagation()` alone would also hide the gesture from the conversation's
wheel listeners further up: releasing stick-to-bottom (`use-sticky-scroll.ts`)
and abandoning an outline jump (`view.tsx`) both listen on the scroll viewport.
An uncancelable `WheelEvent` copy is therefore re-dispatched from the markdown
root, whose propagation path excludes the canvas.
- `touch-action: none`, the pan transform, and the `grab` cursor are inline styles
on the canvas, so `MARKDOWN_BASE_CLASSNAME` overrides all three with
`!important`. Pinning the transform makes the canvas's remaining pointer drag
visually inert without intercepting `pointerdown`, which would have hidden that
event from outside-dismiss and selection handlers above the markdown.

In the viewer, a ctrl- or meta-modified wheel is taken (Chromium spends it on
zooming the window otherwise) and zooms around the pointer. The anchored point is
restored by scrolling the surface, measured from the diagram's box rather than
from scroll offsets, because the surface centres a diagram that fits and that
offset is not proportional to the zoom. A held mouse or pen button pans; a release
that moved the diagram does not count as the click off the diagram that closes.

Two-finger touch pinch is not implemented. Custom pinch requires taking
`touch-action` from the browser, which means reimplementing inertial panning for
the phone case this viewer exists to serve. Touch zooms with the control bar
instead. Invariants: `packages/components/src/components/ai-gui/mermaid-diagram-rendering.md`.

## Alternatives

- **Patch `streamdown`.** Honouring `panZoom: false` in the package would be the
semantically correct fix, and the repository already carries eleven patches. Its
only build artifact is a single-line minified `dist` chunk, so a unified diff
would restate the whole bundle and could not be reviewed.
- **Replace the mermaid block with a custom `plugins.renderers` entry.** Full
control, but it also takes over lazy rendering, the streaming and error paths,
and the diagram copy/download menu, none of which are exported.
- **`pointer-events: none` on the canvas.** Does not help: pointer events only
affect hit-testing, while the propagation path of an event targeted at a
descendant still runs through the canvas.

## Evidence and limits

`tests/markdown-mermaid-fullscreen.test.tsx` renders the real Streamdown block in
jsdom. The new wheel case asserts `defaultPrevented === false` and that a listener
above the message still receives one event of the same `deltaY`; with the
interceptor removed from `markdown-renderer.tsx`, that case fails on
`defaultPrevented`, so it guards the reported defect rather than restating the
implementation. The viewer cases assert that a plain wheel is left alone while a
ctrl-modified one is taken and moves the zoom readout from 100% to 122%, that a
drag moves `scrollLeft`/`scrollTop`, and that the release does not close the
viewer. `computePinchZoomFactor` and `computeAnchoredScrollCorrection` are pure
and unit-tested for reversibility, notch bounding, and the anchored point.

All 16 tests in that file pass, as do the 105 in `tests/markdown*`, and
`packages/components` typechecks. The three CSS overrides compile to `!important`
declarations, checked by running the Tailwind CLI over `src/tailwind/index.css`.

Chromium drove the real `MermaidStyleReview` story through Playwright against a
local Storybook, with the story given a scroll container because the Storybook
preview clips its own overflow. With the fix, a 300px wheel over the diagram moved
that container by 300px — the same as the control wheel over prose beside it — and
the diagram reported `touch-action: auto`, no transform, and a `zoom-in` cursor. A
drag across the preview left its transform at `none` and its release opened the
viewer. In the viewer, a ctrl-modified wheel moved the zoom readout from 121% to
148% without zooming the window, a plain wheel panned the surface from 69 to 177
and left the zoom alone, and a 150 x 120 drag panned to the horizontal limit and by
exactly 120px vertically while leaving the viewer open, which Escape then closed.

Reverting only `markdown-renderer.tsx` in the same session reproduced the report:
the wheel over the diagram left the container at 0 while the control still scrolled
300, `touch-action` was `none`, and the drag left the preview at
`matrix(0.9, 0, 0, 0.9, 100, 60)` — displaced, and still holding a zoom from the
swallowed wheel.

The first CI run failed without a failing test: shifting test timing exposed a
latent teardown race in an unrelated suite, recorded in
[a React commit outside act](../testing/2026-09-09-react-commit-teardown-leak.md)
and fixed in the same pull request.

A later decision partly supersedes this one: a diagram in a message can now be
activated into a canvas by clicking it, and full-screen moved to the block's
action bar. The wheel rule below is unchanged — see
[click to turn a Mermaid diagram into a canvas](../feature/2026-09-10-mermaid-click-to-activate.md).

Limits: touch was not exercised; the `touch-action` fix is a computed-style
observation, not a finger on a phone, and two-finger pinch is absent by design.
The full `pnpm check` was not run — this worktree needs its submodules initialized
to install at all, and the suite reaches far past the changed files. Two full
`packages/components` runs each failed one unrelated test, and a different one each
time (`markdown-streaming-reparse`, then `avatar-cache`); both pass in isolation,
so they are load flakes rather than regressions. `NODE_ENV=production` in the
environment resolves React to its production build, where `act` is missing; the
suite was run with `NODE_ENV=test`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Click to turn a Mermaid diagram into a canvas

Status: implemented
Translation: pending

## Abstract

Taking the wheel away from diagrams in a message
([earlier note](../bug-fix/2026-09-09-mermaid-diagram-gestures.md)) left zooming
reachable only through the full-screen viewer, which is heavy for a glance at one
node. A pointer click now activates the diagram in place: that one diagram
becomes a canvas where a trackpad pinch zooms around the pointer and a drag pans,
released by Escape, a press elsewhere, or the viewer. An unmodified wheel is still
never taken, activated or not, so a reader who forgets they activated a diagram
can always scroll past it — the trap the earlier note removed cannot return.
Full-screen moves to a button in the block's own action bar, and touch keeps
opening the viewer rather than gaining an inline pinch, because a custom touch
canvas would have to reimplement inertial panning for the phone case that viewer
exists to serve.

## Decision

Activation is a pointer affordance with an explicit release, the pattern embedded
maps use: the reader opts into the canvas, so capturing gestures inside it is
honest, and nothing is captured before they do.

- Only a ctrl- or meta-modified wheel on the ACTIVE diagram is consumed. Every
other wheel keeps the previous behaviour — intercepted above Streamdown's
canvas, never `preventDefault()`ed, and re-dispatched as an uncancelable copy so
the conversation's own wheel listeners still see it.
- The transform is written to the `<svg>`, which Streamdown injected as raw markup
and never touches. Streamdown's own pan/zoom canvas stays pinned at
`transform: none`, so its still-live pointer handlers cannot move anything and
cannot fight ours.
- Geometry works in viewport coordinates (`mermaid-inline-canvas.ts`): the frame
rectangle and the diagram's current rectangle are enough to anchor a pinch and
to clamp a pan, and the layout offset cancels out. Modelling Streamdown's
layout instead was tried first and was wrong — it centres a narrow diagram in a
frame the full width of the message, which no transform can reconstruct.
- Releasing resets the transform. The copy in the conversation is a preview, not a
saved view, and a diagram left zoomed would read as a rendering bug.
- Touch does not activate. `pointerType` is recorded from the `pointerdown` that
precedes the click, because `click` does not carry it in every engine.

Keyboard users get the same canvas: Enter toggles it, arrows pan, `+`/`-` zoom,
Escape releases. Without that, activation would be a pointer-only capability
behind a control that is focusable.

## Evidence and limits

`tests/markdown-mermaid-fullscreen.test.tsx` covers the pure geometry (anchored
pinch, re-centring, edge clamping, the zoom ceiling, pinch reversibility) and the
DOM behaviour: a mouse click activates and rings the diagram without opening the
viewer, a pinch scales it, an unmodified wheel over an ACTIVE diagram is still
handed to the page, a drag pans, Escape restores the preview, a press elsewhere
releases it, and a touch tap opens the viewer instead. 27 tests pass;
`packages/components` typechecks and lints clean.

Chromium drove the real `MermaidStyleReview` story through Playwright. Resting: no
ring, and a 300px wheel scrolled the container by 300. Activated: the ring
appeared (`outline: rgb(255, 199, 153) solid 2px`), the same wheel still scrolled
by 300, a ctrl-modified wheel scaled the diagram to 1.284 without zooming the
window, a 60px drag moved it by exactly 60, Escape cleared both transform and
ring, and the action-bar button opened the viewer at 121%.

Limits: after a pinch, Chromium keeps routing the rest of that wheel gesture
stream to the handler that consumed it, so an immediate follow-up scroll in the
same stream does not move the page; a real trackpad gesture ends when the fingers
lift, and the same probe scrolls normally before any pinch. Touch was not
exercised on a device. The activated ring had to be written inline with
`important` after a stylesheet rule — verified correct in isolation and present in
the served CSS — was still outranked by something already applying to Streamdown's
canvas in the Storybook page; the cause was not identified, and the inline write
sidesteps it. The resting cursor comes from that same unidentified rule rather
than from this change.

## Corrections

A review of this change found four defects and one stale comment; all are fixed
in the same pull request, and the cause left unidentified above is now named.

The ring's stylesheet rule was outranked by `tailwind/index.css`'s global
`*:focus, *:focus-visible { outline: none !important }` — activating focuses the
diagram, and specificity does not beat `important`. Inline `important` was
therefore necessary, not merely expedient. Moving the ring back to a stylesheet
was tried and reproduced the failure in Chromium (`outline: 0px none`) before the
rule was found. The resting `zoom-in` cursor was likewise not unexplained: it
comes from a pre-existing `.markdown-renderer [data-streamdown='mermaid'] > div`
rule in the same file, which now carries a `grab` companion for the activated
state.

The defects, each reproduced in Chromium before the fix and re-checked after:

- The viewer closed on a plain click on the diagram. Panning takes pointer
capture on the scroll surface, and pointer capture retargets the following
`click` to the capturing element, so the click arrived with the surface as its
target and read as a click on the backdrop. Where the press started is now what
decides, and a press that really began on the backdrop still closes.
- Escape was answered document-wide while a diagram was activated: the branch sat
above the focus check, so an Escape typed into an input elsewhere was
`preventDefault()`ed and focus was pulled onto the diagram. Every key is now
gated on focus being inside the canvas, and leaving by keyboard releases it.
- The disabled path restored attributes and cleared blocks but never released an
active canvas, leaving its document listeners bound to a detached element.
- The observer re-marked every diagram on every mutation. Removing `tabindex`
from a focused element blurs it in Chromium (checked directly), so a streaming
turn dropped an activated canvas out of the keyboard; `aria-label` was also
rewritten each time. Diagrams are now marked once.

Four tests cover the behaviours and were each confirmed to fail against the
unfixed code. The Chromium blur is outside what jsdom models — it does not
implement the blur — so that test observes the attribute mutations instead, and
the blur itself was verified in the browser.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# A React commit outside act can fail the run from the next test file

Status: implemented
Translation: pending

## Abstract

`tests/mobile-chat-list-preview-cap.test.tsx` committed its renders and its
unmount through `flushSync` rather than `act`, which leaves React's
passive-effect flush queued on the real macrotask queue. That callback reads
`window.event` before it does anything else, so when Vitest tore the file's jsdom
environment down first, it threw as an unhandled error and failed a run in which
all 3313 tests passed. Every commit in the file now goes through `act`, and its
teardown awaits one `setImmediate` so nothing React queued outlives the DOM it
expects. The same `flushSync` pattern appears in dozens of other suites, so this
fixes the file that lost the race rather than the class; a suite-wide sweep is
still open.

## Decision

Route every commit through `act` and set `IS_REACT_ACT_ENVIRONMENT`, as 148
other suites in this package already do. `act` drains passive effects inside the
test, so no scheduler callback is created for them in the first place.

That alone was not enough: one commit per test still escaped, leaving a single
queued callback. Rather than keep hunting a stray update inside a component tree
this file only observes, teardown awaits one `setImmediate` after the unmount.
The macrotask queue is FIFO, so every callback queued before it has run by the
time it resolves — an ordering barrier, not a sleep, and not a wall-clock race.
Both parts are needed: `act` removes the bulk deterministically, and the barrier
closes whatever remains.

## Evidence and limits

The failure mode was reproduced deliberately: a copy of the file with an
`afterAll` that deletes `globalThis.window` and then lets the macrotask queue run
— standing in for Vitest's teardown winning the race — reported three
`ReferenceError: window is not defined` unhandled errors, the same error and the
same file as
[CI run 34323149523](https://github.com/LodyAI/Lody/actions/runs/34323149523).
Moving the commits into `act` took that to one; adding the teardown barrier took
it to zero, with all nine tests still passing. The same probe against
`tests/markdown-mermaid-fullscreen.test.tsx`, which already awaits `act`,
reported nothing, and a minimal render-and-unmount-inside-`act` fixture also
reported nothing — so the leak is a property of commits made outside `act`, not
of React's unmount.

Limits: the CI failure itself never reproduced locally, on eight workers or on
two, pinned to a single core or not; the probe is a deliberate simulation of the
race, not the race. The stray per-test commit that survives `act` was not traced
to its source — the scheduler captures `setImmediate` before a test file can
instrument it — so the barrier is what covers it. Dozens of other suites commit
through `flushSync` and remain latent; they are green today only because their
queued callbacks normally run before teardown.

Related: [Mermaid diagram gestures](../bug-fix/2026-09-09-mermaid-diagram-gestures.md),
whose pull request surfaced this by shifting test timing.
1 change: 1 addition & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@
"sharing.privateHelpTitle": "Private resources are only visible to you",
"sharing.privateOnlyYou": "Private · Only you",
"sessions.addImage": "Add image",
"sessions.diagram.canvas": "Zoom and pan diagram",
"sessions.diagramViewer.close": "Close diagram",
"sessions.diagramViewer.open": "Open diagram",
"sessions.diagramViewer.resetZoom": "Reset zoom",
Expand Down
1 change: 1 addition & 0 deletions locales/zh_CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@
"sharing.privateHelpTitle": "私密资源仅你可见",
"sharing.privateOnlyYou": "私密 · 仅你可见",
"sessions.addImage": "添加图片",
"sessions.diagram.canvas": "缩放和平移图表",
"sessions.diagramViewer.close": "关闭图表",
"sessions.diagramViewer.open": "打开图表",
"sessions.diagramViewer.resetZoom": "重置缩放",
Expand Down
14 changes: 5 additions & 9 deletions packages/components/src/components/ai-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,11 @@ File-by-file ownership and coverage pointers: [README.md](README.md).
dense monospace, terminal output, and collapsed height through
`conversation-font-size-classes.ts`; settings own legacy preset migration.
Keep Streamdown in streaming mode, but never enable word-level `animated`.
- A Mermaid diagram opens in `mermaid-diagram-viewer.tsx`, never Streamdown's own
full-screen overlay (`controls.mermaid.fullscreen` stays off). Keep three
properties: controls padded by the `--safe-area-*` variables rather than a fixed
viewport offset and at least 44px; never a single exit (close button, click off
the diagram, Escape); and `--z-image-viewer` stacking, so a diagram opened
inside a dialog lands above that dialog. Open the diagram at NATURAL size when
it does not fit and pan, instead of scaling it down. `markdown-renderer.tsx`
applies the click target and its `role`/`tabindex` by observer; the block's own
copy/download controls must stay reachable without hover.
- A Mermaid diagram in a message is a still preview until a pointer click
activates it, and an unmodified wheel is NEVER taken — activated or not.
`mermaid-diagram-viewer.tsx` stays the only full-screen surface, reached from
the block's action bar. Invariants:
[mermaid-diagram-rendering.md](mermaid-diagram-rendering.md).
- `chat_failed` raw errors use a modal; extraction/copy live in
`chat-failed-error-report.ts`.
- Capacity retry targets only the latest notice: the first click consents, and
Expand Down
Loading
Loading