Skip to content

fix(session-ui): optimize incremental markdown projection - #55857

Closed
dcerisano wants to merge 12 commits into
anomalyco:devfrom
dcerisano:markdown-incremental-projection
Closed

dcerisano wants to merge 12 commits into
anomalyco:devfrom
dcerisano:markdown-incremental-projection

Conversation

@dcerisano

@dcerisano dcerisano commented Oct 15, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #56202

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Ordinary Markdown streaming re-lexes the entire accumulated message text on every delta. project() in packages/session-ui/src/components/markdown-stream.ts only had a fast path for a still-open fenced code tail; for prose, lists, and tables it rebuilt the tail by re-lexing everything, so a stream of n deltas over a message of final length L is O(n·L) ≈ O(L²) and the UI freezes on long responses.

This extends the incremental fast path to the live Markdown tail. While the last block is mode: "live", project() appends the new delta to that block in place instead of re-lexing, preserving already-frozen completed blocks by object identity.

The relevant correctness fallbacks remain:

  • the tail is re-projected (the tail only, not the whole message) when the delta introduces a blank-line boundary or when the tail is already parked at one; the boundary is checked on the join of the tail's last character and the delta, so a blank line split across two deltas is not missed;
  • an open fence arriving inside a live tail switches it to code mode promptly so streaming highlighting engages;
  • a late reference definition, a frozen display-math delimiter, or a non-append/truncation update falls back to a full re-lex;
  • heal (remend) only touches the segment after the last blank-line boundary;
  • stream() rebuilds the live tail's raw from the source text so a bare trailing list marker (for example 1. ) no longer corrupts later projections.

This also addresses the review note that a blank-line separator arriving across two deltas could leave an oversized tail unsplit.

How did you verify your code works?

  • bun test src/components/markdown-stream.test.ts in packages/session-ui — 54 pass / 0 fail.
  • bun test src in packages/session-ui — 276 pass / 0 fail.
  • bun typecheck in packages/session-ui — no errors in session-ui. Pre-existing errors in schema and ui come from a local Effect version mismatch and are unrelated to this change.

Screenshots / recordings

No visual change — this is a projection/performance fix, so there is nothing to capture.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Grow the open live tail of a streamed markdown projection in place instead
of re-lexing the whole accumulated message on every delta. Previously only a
still-open code tail was extended incrementally; ordinary prose, lists, and
tables re-lexed the full text on each delta, making a stream of n deltas
O(n^2).

project() now:
- preserves frozen completed blocks and extends an open live tail in place,
- re-lexes only the tail when it exceeds LIVE_TAIL_MAX (2048) and the appended
  text introduces a blank-line block boundary,
- falls back to a full re-lex when a late reference definition arrives, so it
  can still merge with earlier frozen uses,
- re-projects the tail when a fence opens inside it, so code mode (and
  streaming syntax highlighting) engages promptly,
- keeps the existing unfinished-code-block fast path and the replacement,
  truncation, and reset fallbacks.

Adds regression tests covering frozen-block identity, bounded tail re-lexing,
oversized boundary-less tails, boundary re-projection, lists, tables, links,
math, reference definitions (single and split deltas), fences, non-append
updates, final incremental vs full projection, and text partition integrity.
Review fixes for the incremental live-tail projection:

- stream(): rebuild the live tail raw from the source text instead of
  marked's normalized token.raw, so a bare trailing list marker such as
  `1. ` no longer corrupts the accumulated projection.
- project(): re-lex from scratch when a frozen block carries a display-math
  delimiter, repairing the block split produced while `$$...$$` is still open.
- project(): heal only the segment after the last blank-line boundary so
  remend cannot rewrite already-complete earlier blocks (for example strip a
  leading escaped \[).

Adds regression tests for each case.
Add a projection micro-benchmark and a size sweep for the
markdown-stream incremental live-tail projection, plus the results in
docs/markdown-stream-benchmark.md. No production code changes.
Rename the feature branch to markdown-incremental-projection (no slash/type prefix, three words) and update the port plan and benchmark report references.
Run prettier over the changed files and add the blank lines the anti-slop
readable-spacing rule requires in the new tests, so lint:changed passes.
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The speed-up is real, but once a reply's live tail passes 2048 characters, whether it ever gets split again depends on how the deltas happen to be chunked. I'd fix that before merge.

How I checked: I called project() from a script against the base and this branch with the same delta sequences. A 3,000-word paragraph streamed one word per delta takes 38.7 s on the base and 0.25 s here. I then sent 60 paragraphs with each blank line arriving as two deltas ("…\n" then "\n"). The base gives 60 blocks with a 231-char live tail. This branch ends with a single 13,850-char live block that never gets split, while stream() on the same text gives 60. markdown-stream.test.ts (53) and all session-ui tests (275) pass, and typecheck passes.

Public API: unchanged. Everything here is internal to @opencode/session-ui.

Concerns:

  • Split paragraph breaks: the boundary check only looks at the new delta (inline comment). Once the tail stops being split, the renderer re-renders the whole block on every delta, so the slow part moves from the worker to the DOM.
  • Leftover docs: docs/markdown-stream-port-plan.md is your planning notes (fork, local branches, remotes) and shouldn't be committed. docs/markdown-stream-benchmark.md creates a new top-level docs/ folder just for one report. Please drop both. If the numbers are worth keeping, a short README in packages/session-ui/performance/markdown-stream/ (like the existing markdown-lifetime and highlighting benchmarks have) or the PR description is enough.
  • Display-math check: when the text contains $, hasFrozenDisplayMath rescans every frozen block on every delta. That keeps a full pass over the message on each delta for any reply that mentions a price or shell variable. It's cheaper than a lex, but please keep it in mind.

Comment thread docs/markdown-stream-port-plan.md Resolved
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

A blank-line separator arriving split across two deltas was invisible to the boundary check, which only looked at the new suffix, so an over-cap live tail could absorb following blocks and never split. Check the join of the tail's last character with the suffix, and freeze a tail already parked at a blank-line boundary. Frozen blocks keep their identity and the incremental projection still matches a fresh projection.
Drop the top-level planning and benchmark docs and keep the harness notes next to the benchmark under performance/markdown-stream/, matching the other session-ui performance benchmarks.
Add a Results section to the markdown-stream benchmark README with a representative baseline-vs-incremental run and the boundary-less scaling sweep.
@dcerisano

Copy link
Copy Markdown
Author

Benchmark results — project() projection CPU

Fresh run on this branch (6c1eb4bad2): origin/dev baseline vs the incremental projection, same harness in the same environment. Bun 1.4.2, Marked 18.0.11, Linux x86_64 (Ryzen Threadripper 1920X), 5 measured passes per workload, medians shown. Absolute times are machine-specific — the ratios and lexer-character counts are the signal. Lexer characters are totals across the 5 passes. These numbers are also now recorded in packages/session-ui/performance/markdown-stream/README.md.

Workload Baseline Incremental Speedup Baseline lex chars Incremental lex chars
many-paragraph-deltas 145.3 ms 9.1 ms 15.9× 4,640,465 92,230
one-long-paragraph (12.6 KB) 10,565.2 ms 92.5 ms 114.3× 63,152,630 20
completed-head-live-tail 241.0 ms 7.9 ms 30.6× 9,151,035 240
mixed-long-response 837.8 ms 21.5 ms 38.9× 30,741,870 316,600
growing-list 406.0 ms 19.1 ms 21.2× 9,164,700 170
growing-table 172.4 ms 13.8 ms 12.5× 6,421,720 50
open-code-fence 1.6 ms 0.8 ms 2.0× 30 30

Boundary-less scaling sweep (scale.bench.ts, one size per row): the baseline lexer work grows quadratically while the in-place tail stays constant.

Words Chars Baseline Incremental Baseline lex chars Incremental lex chars
250 1,576 26.4 ms 5.1 ms 197,240 4
500 3,151 87.1 ms 9.9 ms 789,207 4
1,000 6,316 320.2 ms 30.9 ms 3,157,346 4
2,000 12,626 10,570.6 ms 95.9 ms 12,630,526 4

No measured workload regressed, and project.bench.ts reports 0 partition mismatches (blocks.map(b => b.raw).join("") === text) on every update.

Scope: this measures project() CPU only. It does not cover worker message passing, the Solid reactive store, or DOM/HTML re-parsing (layers tracked separately in #54427 and #54428), and it is not a correctness signal.

@dcerisano

Copy link
Copy Markdown
Author

I'm asking for credit for the diagnosis, measurements, and completed solution — not for the implementation that shipped.

  • Sep 11, 2026 — #48432 (V1 fix) opened.
  • Oct 10, 2026 — #48432 closed by thdxr: "This branch has been upgraded to V2. We're no longer taking PRs for V1. If you think this is still relevant, please port it over and open it for V2." No review.
  • Oct 15, 2026 — #55857 opened, the port. Benchmarks: 15.9× speedup on the 200-paragraph workload (145.3 ms → 9.1 ms) and 114.3× speedup on the 12.6 KB single paragraph (10,565.2 ms → 92.5 ms).
  • Oct 15, 2026 — the opencode-agent review acknowledged "The speed-up is real" and asked for the split-delta boundary fix.
  • Oct 16, 2026 — 37402d29 fixed the split-delta boundary, with the docs and benchmark results updated. The port was complete for review.
  • Oct 16, 2026 — #56202 filed (my issue: markdown projection re-lexes the whole accumulated message on every streaming delta).
  • Oct 18, 2026 — final commit to #55857 (e1272d2).
  • Oct 19–20, 2026 — #57016 created and merged within 72 hours of that final commit, without referencing #55857 or #56202.
  • Oct 26, 2026 — #56202 is still open.

@opencode/patchmark had no public history until #57016.

What I'm not claiming: #57016's implementation. It is a different parser; no code was copied.

What I am claiming: #56202 diagnosed the problem, #55857 measured it and submitted a complete solution, and #57016 includes none of that PR's benchmarks or regression tests.

Asks:

  1. Record in #57016 that the problem was diagnosed in #56202 and that a working solution was submitted for review in #55857 before #57016 was created.
  2. Link #56202 to #57016 so it closes against the fix that landed.

I'm closing #55857 as superseded. The benchmarks and tests that still apply are in #59391.

@dcerisano

Copy link
Copy Markdown
Author

Closing as superseded by #57016. Benchmark and test work that still applies is in #59391.

@dcerisano dcerisano closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app: markdown projection re-lexes the whole accumulated message on every streaming delta

1 participant