Skip to content

feat(plugins): expose the controlled document editor - #2983

Open
tellaho wants to merge 14 commits into
tho/plugin-markdown-panelsfrom
tho/plugin-document-editor
Open

tellaho wants to merge 14 commits into
tho/plugin-markdown-panelsfrom
tho/plugin-document-editor

Conversation

@tellaho

@tellaho tellaho commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Category: improvement
User Impact: Installed document plugins can offer rich-text and Markdown-source editing while preserving paragraphs, hard breaks and clipboard content.

Problem: Plugins need editing without copying app code, while chat serialization and clipboard rules can change a document’s meaning when reused directly.

Solution: Expose a controlled conversation.ui.DocumentEditor using the existing input engine, formatting controls and link dialog with document policy. Plugins own persistence, permissions and mode controls; leaving value unchanged restores a declined edit and caret without another change notification. Read mode uses the parent PR’s shared renderer with document-scale headings, wrapping inline pills and filled, wrapped code blocks capped at a partial bottom line.

This PR is stacked on #2762 and should merge after it. The formatting bar remains formatting-only; the example’s Read/source controls are plugin-owned and sit beside it. Mainline namesake-send qualification, authored-only copying, literal folder paths and office/long-paste behavior are preserved alongside document mode and raw-source/hard-break handling.

Changes

File changes

docs/plugin-authoring-guide.md
Document controlled editing, CommonMark/clipboard policy, optional destinations, limits and plugin-owned saving.

src/features/conversation/service.test.tsx
Verify the injected editor is the existing host component.

src/features/conversation/service.tsx
Expose the existing controlled DocumentEditor through conversation UI injection.

src/features/messages/ComposerFormattingTools.tsx
Reuse persistent formatting controls for document editing.

src/features/messages/ComposerLinkDialog.tsx
Reuse validated links with optional loaded document destinations.

src/features/messages/DocumentEditor.module.css
Keep document-editor layout with its feature owner.

src/features/messages/DocumentEditor.test.tsx
Cover controlled acceptance/decline, caret/history, paragraph and break semantics, source preservation, paste and limits, including office text with an image sidecar.

src/features/messages/DocumentEditor.tsx
Keep the plugin value authoritative while selecting document policy for the existing rich/source input.

src/features/messages/EditableInput.tsx
Apply the selected Markdown policy to budgets and clipboard paths while distinguishing literal source newlines from semantic hard breaks.

src/features/messages/composer-clipboard.test.ts
Retain chat behavior and cover authored-only namesake copying in chat/document modes alongside mainline paste classification.

src/features/messages/composer-clipboard.ts
Export document Markdown and sanitized rich HTML while retaining authored-only copy and literal fallback source.

src/features/messages/composer-document.test.ts
Verify backward-compatible document list-spacing metadata.

src/features/messages/composer-document.ts
Retain document list spacing in the existing editor schema.

src/features/messages/composer-dom.ts
Distinguish document paragraphs from hard breaks in the existing DOM conversion.

src/features/messages/composer-markdown.ts
Preserve chat defaults and namesake send qualification while serializing document structure, literal folder paths and hard breaks.

src/features/messages/document-editor-markdown.test.ts
Compare parsed document meaning across nested structure, hard breaks, inline code, backslashes and bounded fallback source.

src/features/messages/document-editor-markdown.ts
Reuse the bounded Markdown parser and existing composer schema for supported document structure.

src/features/messages/selection-copy.test.tsx
Keep selection-copy behavior asserted with the schema defaults.

src/features/messages/selection-copy.ts
Carry only whitelisted document spacing metadata through sanitized clipboard import.

src/plugins/author.test.mjs
Compile an independent consumer of the public contracts and reject host-only workspace options.

src/plugins/author.ts
Export the supported document editor props, handle and selection types.

tests/browser/document-plugin.mjs
Exercise the injected host UI from an installed module that owns its content and saving.

tests/browser/plugin-import.spec.mjs
Extend the installed-document workflow through rich/source/read modes, formatting, clipboard flavors, links and controlled saving.

Reproduction steps

  1. Install a document plugin using the editor example in docs/plugin-authoring-guide.md. Switch between rich, Markdown-source and read modes; save with Cmd/Ctrl+Enter.
  2. Edit two quoted paragraphs, a loose list and a hard break before inline code. Copy and paste using rich HTML and plain Markdown; read mode retains the same structure. Repeat with a backslash before a hard break.
  3. Load a task list or Markdown table that is retained as raw source. Copy a complete or partial selection and paste it over itself using each clipboard flavor; source mode retains the original lines. Supported prose keeps structured paste and URL-over-selection linking.
  4. Paste office text with an image sidecar; text remains text. Namesake copy retains authored text, and chat sending retains main’s identity qualification and limit accounting.
  5. Leave value unchanged in onChange to decline an edit. The accepted document and caret restore; the next edit and save use that accepted state.
  6. Check the shared document read mode in both themes: long inline snippets wrap, and tall filled fences scroll below a deliberately clipped bottom line.

Validation and limits

Mandatory publication hooks pass on the full editor stack at a88e640f1d: TypeScript and related unit tests, design type checks/guards, and publication policy. The parent 9e00e26fd8 was pushed in the same atomic update; its dedicated PR CI remains authoritative for that parent-only state. The Clippy lane correctly skipped because this stack changes no Rust inputs.

The full shared-Markdown browser file passes 3 cases with 1 existing WebKit screenshot-only skip across Chromium/WebKit, covering both themes, three widths and normal/enlarged text. It checks wrapping, the partial bottom line, scrolling to the end, literal source text and the existing copy interactions. Three filled-code capture journeys pass, including frame/snippet color parity and wheel scrolling in the installed reader/editor. The full installed-plugin file last passed 40/40 before the color-only refinement; fresh PR CI covers the combined update.

A fresh rich/source capture journey also passes on the published editor head, including both themes and a source-value round trip.

Captures use the real built app components with disposable documents and modeled relay/native inventory. They establish browser behavior, not packaged-native, live-relay or operating-system clipboard acceptance. The editor clipboard journey uses page-local ClipboardEvent/DataTransfer. The most recent repository-wide unit run had a dialog Cancel-focus assertion that also failed on its untouched base; no assertion or timeout was changed to hide it. Required review approvals and fresh CI remain pending.

Screenshots

Headless built-app fixtures with disposable documents and modeled relay/native inventory. The rich/source example places plugin-owned mode controls beside the host’s formatting-only bar. Read mode uses the shared document heading scale and code treatment from #2762.

Mode Dark Light
Document read mode Dark document read mode Light document read mode
Rich editing Dark rich document editor Light rich document editor
Markdown source Dark Markdown source editor Light Markdown source editor

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Review round 1: Princess Donut, reviewing exact head 4c86b0a08, stacked on #2762 at bd003eaeb. Posted from Taylor's account on Taylor's behalf.

Verdict: 8/10 with one blocker. Scope is clean and the slice works on top of #2762.

Scope matches the slice. Compared with the combined #2761 head 22e7eb5ee, the only differences are the intentionally removed picker and General access entries in the conversation service, author package, service test, author type test and guide. The editor, conversion, formatting-toolbar and link-dialog source files are identical. Nothing references the picker.

Works on top of #2762 alone. The guide section, author type test and installed-document journey use only the editor plus #2762's Markdown and workspace surfaces. The shared composer changes don't alter existing behavior: the formatting tools only gain an optional always-open mode, and the link dialog keeps its "message" wording by default.

Verified:

  • Unsupported Markdown survives the first edit. Loading and re-saving keeps headings, tables, task lists, HTML, reference links and unsafe links byte-for-byte.
  • The length limit counts the saved Markdown, not just the visible text.
  • Link destinations still pass the host link validator.
  • The editor makes no relay writes.

Blocker

1. The editor keeps showing edits the plugin didn't accept, so what the user sees and what gets saved can differ. (Severity: blocker, controlled-contract and data-loss defect)
The guide describes value/onChange as plugin-controlled, and puts byte limits and permissions on the plugin. But the editor only reloads its content when value changes. If the plugin declines an onChange, for example because it would exceed a UTF-8 byte budget, value doesn't change and the editor keeps displaying the declined text. I confirmed both cases with a temporary component test at this head (deleted afterward):

  • Rich mode: Bold applied to "Hello" calls onChange("**Hello** world"). The plugin keeps "Hello world", but the editor still shows bold "Hello".
  • Source mode: typing "Rejected text" reverts the Markdown field to "Hello world", which is correct. The editor still keeps the declined text internally, though, and switching back to rich mode shows "Rejected text" while value is still "Hello world".

How it fails: a plugin enforcing its byte limit by ignoring over-limit changes, a normal pattern for controlled inputs, shows text it will never save. The user presses Cmd/Ctrl+Enter and the older value is saved, silently dropping what was on screen. Even with a well-behaved plugin, source and rich mode can disagree.

Fix:

  • Stop updating the internal rich-mode content from source-mode keystrokes. Rebuild it from value when switching from source back to rich mode.
  • In rich mode, either restore value when an emitted change isn't adopted, or state in the guide that every onChange must be accepted and limits must be enforced with maxLength, disabled and save-time validation.
  • Add a test where the plugin declines a change in each mode.

Optional

  • Duplicate link destinations collide (nit). Destinations are keyed by href, so two entries with the same URL produce duplicate keys in the picker list.
  • An open link dialog can reappear unexpectedly (nit). If linkOpen stays true when editing becomes false, the dialog is hidden but reopens as soon as editing resumes. The guide could say the plugin should close it when leaving edit mode, or the editor could clear it itself.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Fixed the controlled-editor blocker at 8e538057aea094c570ef76016e94803e97a00e6b.

Declined rich edits restore the accepted value before paint. Source keystrokes no longer seed rich content; returning to rich mode uses the accepted value. Rollback does not emit another change notification. The guide and regressions cover this contract.

Validation: both declined-change tests fail against 4c86b0a08 and pass with the repair; 38 focused checks pass. The complete plugin-import file and the temporary built-app capture/declined-change file pass in Chromium and WebKit (32 checks across two files). The installed-module probe verifies rejected formatting/text/source edits, mode switching, accepted source edits and the exact value saved by Cmd/Ctrl+Enter. Its initial fixture-only source matcher was corrected before the successful full temporary-file rerun. Mandatory hooks pass: TypeScript/related unit tests, design checks, Clippy and push guards.

The split proof remains 8f70f3ee8; the new recombined reference is 5970f19325411f1bf05e0b3289eb98116eb2ba78, exactly that tree plus this three-file repair. Optional nits are deferred. New-head hosted CI, round 2 and native/live/human acceptance remain pending; the PR stays draft.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Review round 2: Princess Donut, scoped to the round 1 blocker and the fix commit 8e538057a. Posted from Taylor's account on Taylor's behalf.

Verdict: 7/10. The original blocker is fixed, but the rollback adds a new blocker and a medium defect. All results below come from temporary component tests run at this head. The probe files are deleted and the worktree is clean.

Round 1 blocker: fixed

  • A declined edit no longer stays on screen or gets saved. In rich mode, a declined edit is restored to value before the next paint. Source-mode keystrokes no longer change the rich-mode content, and switching back rebuilds it from the accepted value. A declined edit can no longer become what Cmd/Ctrl+Enter saves.
  • Accepted edits still work. Accepted typing and accepted source edits behave as before.
  • The regression tests catch the original bug. With only the adapter reverted to 4c86b0a08, both declined-change tests fail and the other five pass. The accepted-source test passes on both heads, which is right, since it guards against the fix breaking accepted edits.

Blocker: after a rollback the caret jumps to the end, so the next keystroke edits the wrong place

(Severity: blocker, correctness and silent unintended edit)
The rollback replaces the editor's content wholesale and doesn't restore the selection. The caret lands at the end of the document.

Reproduced: document "Intro paragraph.\n\nClosing line." with the caret after "Intro". The plugin declines a change that would make the document longer, standing in for a byte limit. Typing "Z" is correctly declined. Pressing Backspace then removes the final period of the document, and the plugin accepts that because it is shorter. The saved value becomes "Intro paragraph.\n\nClosing line". In a separate probe, typing after a declined character appended text at the end ("Hello! world?") instead of at the caret.

Why it matters: this happens in exactly the byte-limit situation the fix exists for. A user at the limit who backspaces to make room deletes text at the end of the document, possibly off-screen, without noticing.

Fix: when rolling back, keep the selection from before the declined change, clamped to the restored text, and keep focus. Add a test for decline, then Backspace or typing, that asserts the result is next to the original caret.

Medium: a declined edit can trigger an unrequested second change with the whole document reformatted

(Severity: medium; Ned specifically asked about second callbacks)
The echo after a rollback is suppressed only when its serialized Markdown equals value exactly. Rich mode normalizes several common equivalent forms: *italic* to _italic_, __bold__ to **bold**, + bullets to -, and 1) numbering to 1.. For any document containing those, a declined edit fires a second onChange with the reformatted document. I observed two callbacks for each of those inputs (for example, a bold edit followed by "_italic_ text"), and one callback for content that round-trips exactly. There was no loop.

Impact: the plugin has no way to tell this from a user edit. A normal plugin accepts it, so the document is marked dirty and its next save reformats syntax the user never touched. The new test asserts a single callback only for "Hello world", which round-trips exactly, so it can't catch this.

Fix: suppress the change that echoes the restored content itself, for example by recording the restored draft's serialization. Don't compare against the raw value. Add a declined-edit test that uses *italic* content.

Out of scope as agreed: the two round 1 nits.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Addressed both rollback defects in 8027f49ed1d2be2bb233b97096026fbac8fbd9f7, one separate commit after 8e538057a.

Declined edits restore the host editor’s last accepted checkpoint, including selection and undo state. Restoration avoids an edit transaction, and programmatic synchronization does not notify the plugin, so equivalent Markdown spellings cannot cause a second normalized change.

Five new cases fail against the prior adapter and pass with the repair. All 43 focused checks pass. The full temporary installed-module editor file passes in Chromium and WebKit (4 checks), including decline→Backspace at the original caret, exact saved content, and single callbacks/raw source for *italic*, __bold__, bullets and numbering. Existing source/link/formatting workflows and eight fresh captures pass. Mandatory hooks pass: TypeScript/related unit tests, design checks, Clippy and push guards.

The new recombined tree 39975f2c9a5d6415624446863bc60b8ba357b82b equals the original extraction plus only the two accepted editor repair commits. The PR stays draft; new-head CI, round 3 and native/live/human acceptance remain pending. Optional nits remain outside this repair.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Review round 3: Princess Donut, scoped to the round 2 findings and the fix commit 8027f49ed. Posted from Taylor's account on Taylor's behalf.

Verdict: 9/10, no blockers. Both round 2 findings are fixed, and I found no new defects. I checked with temporary component tests at this head; the probe files are deleted and the worktree is clean.

Round 2 blocker (caret after rollback): fixed

The editor now restores its last accepted state using the engine's existing checkpoint, which covers the document, selection and undo history. The caret no longer moves.

  • My original repro now behaves correctly. Document "Intro paragraph.\n\nClosing line.", caret after "Intro", a declined "Z", then Backspace. The result is "Intr paragraph.\n\nClosing line." with the caret at 4, so the deletion lands at the original spot.
  • It also covers caret moves that don't re-render. I accepted an edit, moved the caret to position 2 without any re-render, typed a declined "X", then typed "Y". Result: "HeYllo world!", with Y at the moved caret.

Round 2 medium (second change callback): fixed

Restoring no longer goes through an edit transaction, and echoes during the sync are suppressed.

  • One callback per declined edit. *italic*, __bold__, + bullets and 1) numbering each produced exactly one callback for the declined edit.
  • Typing still works after a decline. The next accepted keystroke is delivered normally. Its rich-mode Markdown normalization is existing behavior, not a regression.

Do the new tests catch regressions? Yes

With only the adapter reverted to 8e538057a, five of the twelve editor tests fail: the caret-and-Backspace test and all four normalized-rollback tests. The other seven pass. At this head all twelve pass.

Defects introduced: none found

  • Undo history is kept. After an accepted "A", a declined "X", then Ctrl+Z, the result is "Hello". That undid the accepted edit, with nothing left over from the declined one.
  • Accepted and repeated edits work. Three declines in a row followed by an accepted keystroke gave "akbc" with four callbacks, as expected.
  • The sync flag can't get stuck. I checked whether it could stay set and swallow a later edit. A rollback always follows a draft change, so there is always a re-render to clear it.

Optional

  • Undo after a decline has no test (low). I verified it with a probe only. A short regression test would lock it in.
  • The selection listener re-subscribes on every render (nit). It's harmless, but it could be limited to when the input element changes.

@tellaho
tellaho marked this pull request as ready for review November 7, 2026 12:06
@tellaho
tellaho requested a review from a team as a code owner November 7, 2026 12:06
@tellaho
tellaho force-pushed the tho/plugin-markdown-panels branch from bd003ea to 2df7cd4 Compare November 7, 2026 20:32
@tellaho
tellaho force-pushed the tho/plugin-document-editor branch from 8027f49 to c8129c6 Compare November 7, 2026 20:33
@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Conflict-only rebase onto #2762 at 2df7cd4bd; head c8129c6f4. Preserved main’s guide additions and both reviewed editor repair patches. No feature behavior changes. The full plugin-import file passes in Chromium and WebKit (34 checks), and mandatory push hooks pass. Ready-for-review state preserved; new-head CI pending.

@kalvinnchau kalvinnchau 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.

🤖 Two findings: rich edits can change persisted Markdown paragraph semantics, and the unconditional bracket shortcut prevents literal bracket entry.

@tellaho
tellaho force-pushed the tho/plugin-document-editor branch from c8129c6 to 03617c5 Compare November 8, 2026 00:47
@tellaho

tellaho commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Addressed both review findings in 03617c50e, restacked on #2762 at d5e4786d5.

  • Document serialization now follows CommonMark paragraph and hard-break semantics recursively through quotes and lists. Enter creates a paragraph; Shift+Enter creates a hard break. The existing editor uses the same serializer for change notifications and editing limits; chat keeps its existing default behavior.
  • The [[ shortcut opens the link dialog only when destinations are supplied. Omitted/empty destinations allow literal brackets; the destination shortcut remains covered.

The paragraph/break probes failed before the fix, and both literal-bracket cases failed before the destination guard. Adapter/composer files pass 82 checks; the editor component file passes 21. The full installed-plugin browser file passes 34 Chromium/WebKit checks, including native Enter/Shift+Enter and reader output. An additional temporary installed-module probe passes in both engines for literal brackets and rich/read structure. No permanent browser cases were added or removed; the existing document journey gained assertions.

Browser checks and these captures were made at 730145de1; final 03617c50e changes only test setup to omit an optional prop correctly and exact list-node JSON expectations to include the default spacing field. Production, docs and browser fixture bytes are identical. The final component file, 44 copy/clipboard checks and mandatory hooks pass. The initial native probe needed a source-field reset because filling contenteditable retained its quote container; assertions were kept. The first push hook caught the test typing issue, which was fixed before publication. The broader hook then found two exact list-node JSON fixtures needing the new default spacing metadata; their HTML/Markdown assertions were unchanged, and both full files pass after updating those expectations.

Built app with an installed example, disposable data and modeled relay/native inventory. Wide-viewport captures show the workspace pane; narrow uses enlarged text. Native packaged/live-relay and human acceptance remain pending. New-head CI and review remain pending.

Theme / viewport Rich text Reader
Dark, wide Dark wide rich editor Dark wide reader
Dark, narrow Dark narrow rich editor Dark narrow reader
Light, wide Light wide rich editor Light wide reader
Light, narrow Light narrow rich editor Light narrow reader

@kalvinnchau kalvinnchau 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.

🤖 Three document serialization defects remain: hard breaks before inline code, clipboard structure loss, and Shift+Enter after a typed backslash.

@tellaho

tellaho commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

🤖 @kalvinnchau Fixed all three document serialization issues at b686dbec1.

  • e30fa42a0: hard breaks before inline code use the same break-aware leaf as other prose, including within quotes/lists.
  • 39bf5c75f: an odd authored trailing backslash cannot consume the generated hard-break marker. Typed C:\ plus Shift+Enter retains its line break.
  • b686dbec1: document mode governs edit budgets and both clipboard flavors. Own HTML retains hard-break nodes and whitelisted list-spacing metadata; normal plain Markdown paste uses the existing bounded adapter. Plain prose retains caret formatting/spaces, while paste-as-plain-text and default chat paths remain intact.

Validation: four inline-code regressions, three slash regressions and seven of eight clipboard structure cases fail before their fixes. The four complete affected unit files pass 114 checks. The final-head installed-plugin file passes all 34 Chromium/WebKit checks, including page-local clipboard events, reader structure, inline paste formatting and native typed Shift+Enter. The current-head capture journey passes in both engines (2 checks). Mandatory hooks pass. No permanent browser cases were added or removed; the existing installed-document journey gained assertions and component/conversion tests own the matrix.

The browser clipboard checks use page-local ClipboardEvent/DataTransfer; the operating-system clipboard, packaged native build, live relay and human acceptance remain untested. New-head CI/review remain pending.

Built app at b686dbec1, installed example, disposable data and modeled relay/native inventory. Wide captures show the workspace pane; narrow uses enlarged text. Images show structure after both clipboard flavors.

Theme / viewport Rich text Reader
Dark, wide Dark wide rich editor Dark wide reader
Dark, narrow Dark narrow rich editor Dark narrow reader
Light, wide Light wide rich editor Light wide reader
Light, narrow Light narrow rich editor Light narrow reader

@kalvinnchau kalvinnchau 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.

🤖 One document clipboard regression remains: unsupported multiline source is corrupted by both paste flavors.

Comment thread src/features/messages/EditableInput.tsx Resolved

@kalvinnchau kalvinnchau 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.

🤖 Partial selections still corrupt raw task-list/table source in both clipboard flavors; full-selection round trips are repaired.

Comment thread src/features/messages/EditableInput.tsx Resolved

@kalvinnchau kalvinnchau 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.

🤖 One new paste regression remains in paragraphs containing a hard break; the prior partial raw-source selection defect is repaired.

Comment thread src/features/messages/EditableInput.tsx Resolved
kazemek21 added a commit that referenced this pull request Nov 9, 2026
… an update (#3024)

## TL;DR

A `buzz://plugin-install` Git link pinned to both a `commit` and a
`path`, for a plugin already installed from that same repository and
folder, now opens the **Update** review that a plugin's Git
`requestInstall` already gets. If the link names another branch or tag
than the plugin tracks, the review says so. Before, it hit the Add
path's **Already installed** refusal, so there was no reviewed way to
update from such a link. Links without both pins, and links to a plugin
installed from another source, are still refused as duplicates.

## What changed

- When the installed plugin was imported (not bundled), `PluginImport`
treats a link with both `commit` and `path` like a bound
`requestInstall` request when deciding between update and duplicate
(`src/app/PluginImport.tsx`). It reuses the existing same-source check:
the installed `updateSource` must be a Git source whose repository
matches the fetched preview's source and whose folder matches the
candidate's folder, and the candidate must have the installed manifest
ID. Native import saves the requested `ref`, and later update checks
follow it. So when a link's or plugin request's `ref` differs from the
installed Git source's ref, the review adds "Future updates will follow
<new> instead of <old>." An empty ref reads as "the repository default".
- Confirming a requested update (link or plugin request) re-reads the
live catalog and refuses with "The installed plugin changed. Close this
review and check again." if the installed revision, enabled state or
saved source changed after the review opened, so the review's "stays
disabled" line can't go stale.
- A requested update of a disabled plugin now says "This plugin will
stay disabled.", as the Details **Update** review already does. Native
install already keeps the enabled state, and the review offers no enable
switch for updates.
- Docs: `docs/plugin-architecture.md`, `docs/deep-links.md` and
`docs/buzz-help.md` describe the pinned-link update.

Protections that still apply: the commit pin (native refuses a ref whose
tip differs, and the review re-checks the fetched commit), the folder
filter, the access review against the installed manifest, the publisher
line and native publisher-continuity refusal, the unsigned-to-signed
rollback warning, explicit **Update** confirmation, and preview discard
on cancel. Links with only `commit`, only `path`, or neither, and pinned
links to a bundled plugin, keep showing **Already installed** / **Open
in Buzz**.

Not in scope: update discovery, Update-all, managed policy feeds or
checkers (#2504, #2621, #637), signature or publisher-trust policy
(#2212, #2746), and the compact request dialog (#2625). This PR doesn't
touch their hunks. #2983 and #2762 also edit
`tests/browser/plugin-import.spec.mjs`; this PR leaves that file
unchanged.

## Supported operation

On a Buzz desktop build that includes this change, open
`buzz://plugin-install?repository=<repo>&ref=<ref>&path=<folder>&commit=<40-or-64-hex>`
for a plugin whose installed copy came from a Git import of that same
repository and folder. The review shows **Update <name>**, with access
changes listed against the installed version, and states any change to
the branch or tag later updates follow. Nothing changes until you choose
**Update**. Native import still refuses the update if the ref's tip
isn't the pinned commit, or if the publisher changed or a signed plugin
became unsigned.

## Tests

New `src/app/PluginImport.test.tsx` cases, all with fictional
`example.com` fixtures:

- A pinned link to the installed source reviews as **Update**, installs
the pinned folder, and doesn't call enable. This is the regression test
for the original failure.
- A disabled plugin stays disabled and the review says so.
- A pinned link with another ref than the installed plugin tracks (a
named ref, or the repository default) reviews as **Update**, says which
ref future updates will follow, and installs on **Update**. A same-ref
link shows no such line.
- A link without both pins (none, path only, commit only), or a pinned
link to a bundled plugin, still shows **Already installed** with **Open
in Buzz**.
- An installed source in another repository or folder, a local folder,
or an unknown source is refused with the source-mismatch message and
**Open in Buzz**.
- When only another plugin ID is installed from that source, the link
offers a new install.
- A fetched commit that differs from the pin offers no **Update**.
- Cancel discards the preview without installing.
- If the installed revision, enabled state or source changes after the
review opens, **Update** is refused, for both a link and a plugin's
`requestInstall`.

The request and link harnesses now return a ready catalog from
`plugins.snapshot()`, which the new confirm-time check reads.

Checked on a Linux box at `37cf73e1`: `biome check` on both files, `pnpm
typecheck`, and `vitest run src/app/PluginImport.test.tsx` (100 passed).
After the first review fixes, the same three checks passed locally (102
tests), and the bundled-plugin test fails on the earlier predicate. The
later enabled-state drift case (two lines plus one test input) passed
biome locally; host load refused a local test run, so PR CI covers it.
With only the `requested` line reverted, 6 of the 8 `pinned link` tests
fail. Against `main`'s `PluginImport.tsx`, the same 6 fail and the other
94 pass. The two that pass either way are the existing pin-retry test
and the other-plugin-ID new-install case, which should behave the same
before and after.

After review feedback, the tracked-ref disclosure was verified on a Blox
box at `e913727f`: `biome check`, `pnpm typecheck` and `vitest run
src/app/PluginImport.test.tsx` (103 passed). With the review line
disabled, the new tracked-ref test fails. The before/after screenshots
predate this line and show a same-ref update.

### Before / after

These screenshots come from a temporary Chromium journey using the
existing `nativeImports` IPC fixture and fictional `example` data; the
journey is not committed. It installs `example.linked` from
`https://github.com/example/plugins` at `plugins/linked`, then delivers
the same pinned link again with a newer candidate.

| | Light | Dark |
|---|---|---|
| Before (`main`) |
![t027-before-light](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-before-light.png)
|
![t027-before-dark](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-before-dark.png)
|
| After |
![t027-after-light](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-after-light.png)
|
![t027-after-dark](https://d24qwcpro867f5.cloudfront.net/repos/block/buzz-app/prs/3024/t027-after-dark.png)
|

## Remaining gaps

- No native (Tauri) run: the Playwright screenshots use the existing IPC
fixture. Native import, publisher continuity and the deep-link handoff
were not changed here.
- No human has tried it in the app yet, so `buzz-review-completed` is
not set.

## Initiative context

Status: https://g2.sqprod.co/apps/kylez-buzz-improvements-status
(buzz-improvements T027).

Fixes NEXUS-4327

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kyle Zemek <kylez@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@tellaho
tellaho force-pushed the tho/plugin-markdown-panels branch from d5e4786 to 7082669 Compare November 10, 2026 05:12
@tellaho
tellaho force-pushed the tho/plugin-document-editor branch from ed63a40 to 416b9ba Compare November 10, 2026 05:12
kazemek21 added a commit that referenced this pull request Nov 12, 2026
…ments (#3322)

## TL;DR

Plugins can now register a headless composer annotator with
`ctx.conversation.registerAnnotator`. The host gives it the focused
draft, validates the ranges it publishes, draws the marks, and replaces
text only when the person picks one of the offered replacements, as one
undo step. Checking, language, scheduling, timeouts and wording all stay
in the plugin, so a spelling, grammar or style checker can ship as a
standalone plugin.

## API

```ts
ctx.conversation.registerAnnotator({ id, title, component });
// component: ComponentType<CompletionContext & {
//   observation: { revision, text, prose: { start, end }[] };   // frozen, one per document
//   publish(annotations: ComposerAnnotation[]): (() => void) | false;
// }>
// ComposerAnnotation  = { start, end, text, label, kind?: "spelling" | "grammar", replacements?: { text, label? }[] }
```

The types are exported from `@buzz/author`. The full contract is in
`docs/plugin-architecture.md` under "Composer annotators".

**Who owns what.**

| Host | Plugin |
|---|---|
| Mounting per focused composer, destination and enablement; revoking
`publish` on change, removal or render failure | What to check,
language, ignore rules, tokenization |
| Draft projection, prose ranges and UTF-16 mapping | Debounce,
timeouts, retries, failure policy |
| Validating revision, expected text, prose containment, grapheme
boundaries, labels and replacements; caps | Results, labels, replacement
choice and ranking |
| Underline, `aria-invalid`, right-click / ⌘. menu, focus return,
one-step Undo, IME and read-only refusal | — |

**Disclosure.** An annotator sees what completion providers already see,
under the same eligibility (focused, enabled, outbox-capable composer),
plus the prose ranges. It never sees the caret, and no observation is
offered while unfocused, disabled, read-only or composing.

**Publishing.** `publish` is bound to one observation. It returns
`false` once the draft changed, the destination or enablement changed,
the annotator was removed or failed to render, or the value is not an
array. Otherwise it replaces that annotator's marks and returns a
disposer that withdraws only them. A disposer for an earlier draft
leaves the mapped underlines in place until the next publication, so
per-observation cleanup does not flicker. The menu closes when its
annotation is withdrawn or replaced, even for the same draft.

**Validation and bounds.**
- An annotation must match its text exactly, lie inside one prose range,
not split a surrogate pair or grapheme cluster, and carry a 1–120 unit
label.
- Replacements: at most 5, distinct, at most 64 units, different from
the marked text. An empty replacement deletes the range and needs a
label.
- No control, bidi or line-separator characters and no lone surrogates.
- At most 200 annotations per publication and 400 marks per editor;
earlier annotators win overlaps; at most one redraw per animation frame.

**Applying a replacement.** The host rechecks revision, range, text,
editability and composition; refuses mixed formatting; keeps the range's
formatting; and closes history on both sides so typing, the replacement
and further typing undo as three steps.

## Review notes

- Adds an extension point to three `FOUNDATION` files:
`conversation/contracts.ts`, `conversation/service.tsx` and
`plugins/author.ts`. The change is additive and needs FOUNDATION review.
- The native spellcheck default and autocorrection preference are
unchanged.
- Related open work this does not overlap: native spellcheck default
(#2201, #2546), a generic draft read/replace API (#2656), other composer
work (#2983, #3312). Other open PRs touching the same composer files:
#2988, #3260, #3257, #2896.

## Tests

- `composer-annotations.test.ts` (Node): prose-range exclusions,
same-text revisions, range/grapheme/label/replacement validation and
caps, overlap and merge, mark-preserving replacement and deletion with
Undo/Redo, stale and mixed-format refusal, composition mapping, the
hidden mark on the word being typed.
- `ComposerAnnotators.test.tsx` (jsdom, annotator registered through the
real `PluginRuntime` and `ConversationService`): observation shape and
context, emoji offsets, keyboard and right-click menus, Escape and focus
return, replacement with Undo/Redo, `aria-invalid` kinds, stale and
invalid publications, disposer ownership, menu closing on same-draft
withdrawal or replacement, earlier underlines kept across a stale
disposer, an immediately publishing annotator surviving a destination
change, same-text structural change, retirement on disable, destination
change, plugin removal and render failure, IME composition, disabled and
read-only editors, unmount.
- `MessageComposer.test.tsx`: the real composer wiring, including
retirement on a destination change.
- No browser journey was added; jsdom covers the contract and the
remaining gaps are native-only.

On a Linux remote box against this head rebased on current `main`: `pnpm
check` (Biome, `tsc`, design typecheck and design checks) passed, and
`vitest run` over `src/features/conversation`, `src/plugins`,
`EditableInput`, `MessageComposer` and `composer-annotations` passed (35
files, 1374 tests). The four new lifecycle tests fail against the
previous source and pass with the fix. The local pre-push hook was
skipped because CI runs the same checks.

**Not yet verified:**
- right-click selection and context-menu behavior in native WebKit;
- whether ⌘. reaches the webview on macOS;
- screen-reader announcement of `aria-invalid`;
- real IME composition in the desktop app;
- both themes, narrow and wide widths, and enlarged text, with a real
annotator installed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Kyle Zemek <kylez@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
tellaho and others added 5 commits November 13, 2026 12:36
Reuse the host editing engine, prototype Markdown adapter, persistent formatting and generic link destinations through conversation.ui.DocumentEditor. Keep content, source mode, link intent and save policy controlled by the plugin.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Keep rich text synchronized with the accepted controlled value, and rebuild it from accepted source content instead of source keystrokes. Suppress rollback echoes through onChange and cover declined rich/source edits plus accepted source transitions.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Restore the last accepted editor checkpoint so a declined edit retains selection and undo state without a normalized change callback. Cover a following Backspace and equivalent italic, bold, bullet and numbered Markdown forms.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Use opt-in CommonMark serialization for documents, retaining nested paragraph/list structure and marked hard breaks. Keep chat defaults and align document Enter, Shift+Enter and serialized editing budgets with the reader.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Leave literal double brackets editable when there are no destinations, while preserving the existing destination shortcut. Cover omitted and empty choices plus the opt-in path.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
tellaho and others added 9 commits November 13, 2026 12:36
Route prose before code spans through the hard-break-aware leaf. Cover root, quote and list round trips.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Escape an odd authored trailing slash before the generated hard-break marker. Cover slash runs and typed Shift+Enter without changing chat output.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Use document policy for wire output, editing budgets and clipboard import/export. Retain hard breaks and list spacing in owned HTML, parse plain Markdown through the existing bounded adapter, and keep default chat clipboard output unchanged.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Retain embedded fallback source newlines during plain paste and carry them through owned HTML with the existing source-span codec. Keep supported prose normalization and default chat behavior unchanged; cover task lists, tables and supported prose in both clipboard flavors.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Use the raw destination context before interpreting copied fragments as document blocks. Keep task-list and table source intact through both clipboard flavors, with partial-selection regressions beside the existing full-selection cases.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Inspect text children for raw-source newlines so document prose with hard breaks keeps structured paste and URL-over-selection behavior. Cover both clipboard flavors in hard-break and ordinary prose destinations while retaining the raw-source regressions.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Cover authored clipboard output in chat and document modes after integrating namesake send qualification. Keep written names and explicit qualifiers literal in document source without signed recipients.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Exercise the shared paste path with office text and its image sidecar in the document editor, alongside main's existing composer and clipboard classification coverage.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@feedback.communities.buzz.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>

This branch has not been deployed

No deployments
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.

2 participants