docs: explain native prose selection boundaries and tradeoffs

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:01 +02:00
parent 75907b360b
commit e831814d73
3 changed files with 48 additions and 12 deletions
+1
View File
@@ -49,6 +49,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
| [docs/native-text-selection.md](docs/native-text-selection.md) | Native prose runs, selection boundaries, renderer flavor and library constraints |
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
+11 -12
View File
@@ -12,7 +12,7 @@ provider deltas (every provider streams incrementally)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ source-item plugin transforms → native Markdown blocks / tool grouping
→ source-item plugin transforms → Markdown presentation / tool grouping
→ paced reveal (app, per displayed item) → paint
```
@@ -29,17 +29,16 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
completed blocks by object identity, and caching the split per source item so a tail change does
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
one message two shapes depending on how it arrived, and message-addressed features only worked on
one of them.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
- **Markdown rows belong to presentation, and every assistant message is a block group.**
`agent-stream/presentation.ts` keeps the source message whole for plugins, then splits it for
display. Native groups adjacent prose into selectable runs; web retains individual blocks.
Code, tables, images, HTML-like content and blockquotes end a native run. See
[native text selection](native-text-selection.md) for renderer ownership and library constraints.
Appends parse only the growing last source block. Completed source blocks retain their parsed
classification, completed display rows retain object identity, and a source-item cache avoids
re-splitting history. Streamed and fetched messages use the same projection.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}`; native prose runs use `${messageId}:run:${n}` with
`blockGroupId = messageId`, including single-row messages. Inline images split a source block into offset-keyed parts, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
+36
View File
@@ -0,0 +1,36 @@
# Native text selection
Assistant prose uses `react-native-enriched-markdown` 1.1.1 on iOS and Android. Native selection belongs to one text view. Putting paragraphs in separate FlatList rows prevents dragging a selection between them.
Presentation groups adjacent paragraphs, headings, lists and horizontal rules into prose runs. Code, tables, images and HTML-like content keep Paseo's existing renderers. Blockquotes are separate rows rendered by the prose module. Selection stops at these boundaries; consecutive paragraphs inside a quote share its text segment. Nested quotes and embedded block content introduce further library segments.
Keep this boundary in `agent-stream/presentation.ts` and `components/markdown-prose/`. The source message stays whole for plugins, copying a turn and persistence. Web keeps its existing block rows and renderer. The platform-specific `markdown-text` components remain in use by the preserved blocks and other Markdown surfaces.
## Flavor
Use `github`. It retains strikethrough but renders blockquotes in separate native views. `commonmark` keeps a single text view across quotes but disables strikethrough and task-list parsing. A compiled parser check against the published 1.1.1 package produced:
```text
commonmark: strike=0 tasks=0 quotes=1 spoilers=1
github: strike=1 tasks=1 quotes=1 spoilers=1
```
Paseo's existing parser does not implement task lists. GitHub flavor draws checkboxes for `[ ]` and `[x]`; task toggling is disabled. The checkboxes are visible but cannot be toggled.
## Compatibility constraints
- Disable `latexMath`. Dollar-delimited text must stay literal. Disable admonitions too; they would reinterpret existing quote content.
- Spoilers have no public parser switch in 1.1.1. Prepare inline text with escaped pipes so `||text||` stays visible. Preserve pipes inside code and link destinations.
- `lineHeight` is a minimum. Use the Markdown theme's line height; font metrics can still increase it.
- Strikethrough color is iOS-only. Android uses the surrounding text color.
- Native h1/h2 lose their 1px bottom borders: the library has no heading-border style API. H6 plain text is uppercased before rendering; inline code and link destinations keep their source casing. H6 loses its 0.5 letter spacing. Size, weight, color, margins and line height keep the existing heading tokens. Web headings are unchanged.
- Published 1.1.1 has no `linkRecognition` or `linkPillContent` API. Explicit links use Paseo's link handler. Inline-code file candidates become ordinary links carrying their original resolver input. Native hover tooltips and custom React link pills are not available inside the text view.
- Reuse Paseo's provisional inline parser for the streaming tail so unfinished emphasis, code and links retain their existing display. Streaming updates replace the attributed text. Selection made while the selected run is still changing is not retained by the library. Keep the existing paced reveal and leave the library's additional streaming animation disabled.
The app's `enriched-markdown` package configuration disables the math engine, code highlighting, all tree-sitter grammars and video support. Paseo owns those block renderers; shipping their duplicate native engines adds binary size without serving this surface. Rebuild the dev client after changing this configuration.
Android inverted timelines retain their scroll protection in `modules/paseo-scroll`. Test selection-handle dragging against the compiled client: a JavaScript-only test cannot prove that Android's child-reveal request leaves the scroll offset unchanged.
## Follow-ups
Contribute heading `borderBottomWidth` and `borderBottomColor` style props upstream so native h1/h2 can restore their existing underline without breaking selection across headings.