Skip to content

Suggest mode 7/9: inline live wiring - #80433

Open
adamsilverstein wants to merge 153 commits into
suggest/review-uifrom
suggest/inline-wiring
Open

adamsilverstein wants to merge 153 commits into
suggest/review-uifrom
suggest/inline-wiring

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

Part of #73411

What's in this PR

Wires the inline suggestion engine into the live editor: the addition,
deletion, and format keyboards capture typing / delete / cut / paste /
format toggles on beforeinput and turn them into inline markers; the
content reconciler covers the onChange seams (IME, autocorrect, drag,
multi-line paste); the overlay HOC hands format and content edits off to
those paths before the overlay runs; author colors tint markers per
suggester; annotations decorate them on the canvas; and the note garbage
collector prunes orphaned suggestion notes, sparing any note somebody has
replied to (fixes #81958, folded
in from #81997). Restores the inline apply/reject
branches in the provider, mounts the singletons in the editor provider, and
adds the server-side inline marker strip.

The architecture document moved out to #82047 and the end-to-end suites to
#82048, so this PR is production code and its unit tests only.

Screenshot

With the inline layer wired up, all three marker types render live in the canvas and each opens a note summarizing the proposal:

Editor canvas showing an underlined addition, a struck-through deletion and a dotted-underline formatting suggestion, with Add, Delete and Formatting notes alongside

Testing

This is one layer of the stack. To exercise the whole feature, #78994 bundles every layer into one branch and builds it in Playground:

Test in WordPress Playground

👉 https://playground.wordpress.net/gutenberg.html?pr=78994

Enable Gutenberg > Experiments > Collaboration > Suggestion Mode, then follow the walkthrough in #73411, which also explains how to review the stack layer by layer.


Suggest mode stack

This rebuilds the manually-stacked Suggest mode work (#73411) as a GitHub Stack of 9 small, independently reviewable PRs, each building on the one below it:

  1. #80427 - editor intent (edit/suggest/view) + experiment gate
  2. #80428 - suggestion storage, REST controller, provider
  3. #80429 - block-level capture (attribute + structural)
  4. #80430 - inline marker primitive
  5. #80431 - inline suggestion operations
  6. #80432 - review UI (Apply/Reject sidebar + summary)
  7. #80433 - inline live wiring
  8. #82047 - architecture documentation
  9. #82048 - end-to-end test suite

Each follow up fix now sits in the layer that owns the code it changes, rather than piling onto the top of the stack. The whole feature can be exercised end-to-end via the combined testing branch #78994 (Playground). Behind the "Suggestion Mode" experiment (Gutenberg > Experiments).


AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

Wires the inline suggestion engine into the live editor: the addition,
deletion, and format keyboards capture typing / delete / cut / paste /
format toggles on beforeinput and turn them into inline markers; the
content reconciler covers the onChange seams (IME, autocorrect, drag,
multi-line paste); the overlay HOC hands format and content edits off to
those paths before the overlay runs; author colors tint markers per
suggester; annotations decorate them on the canvas; and the note garbage
collector prunes orphaned suggestion notes. Restores the inline apply/reject
branches in the provider, mounts the singletons in the editor provider, adds
the server-side inline marker strip, the architecture doc, and the full e2e
suites (golden path, persistence, review, undo, overlay invariants).
@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Size Change: +14.6 kB (+0.18%)

Total Size: 7.95 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 478 kB +122 B (+0.03%)
build/scripts/core-data/index.min.js 37.2 kB +6 B (+0.02%)
build/scripts/editor/index.min.js 610 kB +13.9 kB (+2.33%)
build/styles/block-editor/style-rtl.css 20.2 kB +176 B (+0.88%)
build/styles/block-editor/style-rtl.min.css 17.2 kB +121 B (+0.71%)
build/styles/block-editor/style.css 20.2 kB +179 B (+0.89%)
build/styles/block-editor/style.min.css 17.2 kB +121 B (+0.71%)

compressed-size-action

@adamsilverstein adamsilverstein added No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core [Type] Feature New feature to highlight in changelogs. labels Jul 17, 2026
Input events target the editing host. With the editableRoot block support
(native cross-block selection, #79105) that host is the writing-flow wrapper
whenever the selected block has editable siblings, so event.target no longer
identifies the block's rich text. The suggest-mode keyboard interceptors then
never engaged and typing fell through to the per-keystroke reconciler path,
which drops every keystroke after the first (each plan re-validates against a
content snapshot the previous write already moved) - a typed addition in any
multi-paragraph document lost all but its first character.

Resolve the affected element from the event's target range (or the live
selection for clipboard events, which expose no target ranges) instead of
event.target, in both isEventTargetSelectedRichText and readEventRange.

Also harden the tinting e2e: assert the typed run lands inside the marker
(an attribute-only assertion missed the text loss) and assert each marker
resolves its author's exact palette color rather than merely differing, so
a user-id collision on the 7-color palette cannot fail the test.
@adamsilverstein

Copy link
Copy Markdown
Member Author

The markers are tinted per author e2e failure was not a flake - it exposed a real interaction with trunk's #79105 (editableRoot block support, landed after this stack's reference was last e2e-green). Input events target the editing host, and in a multi-paragraph document that host is now the writing-flow wrapper - so the suggest-mode keyboard interceptors, which identified the affected block from event.target, never engaged. Typing then fell through to the content-reconciler path, which drops every keystroke after the first: a typed addition in any multi-paragraph document lost all but its first character.

Fixed in 1b71c34 by resolving the affected element from the event's target range (or the live selection for clipboard events) instead of event.target, in isEventTargetSelectedRichText and readEventRange - this covers the addition, deletion, and paste interceptors. Also hardened the e2e: it now asserts the typed run lands inside the marker (the attribute-only assertion missed the text loss) and that each marker resolves its author's exact palette color, so a user-id collision on the 7-color palette can't produce a false failure.

All 64 suggestion-mode e2e tests pass locally. Cherry-picked to the combined testing branch (#78994) to keep it byte-equal with the stack tip.

@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Flaky tests detected in 71e6c55.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/33117806892
📝 Reported tests:

refuses the drop and uploads nothing in /test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js, passed after 1 failed attempt.
Error: expect(received).toHaveLength(expected)

Expected length: 0
Received length: 2
Received array:  [{"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=106"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/106", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/104", "id": 104, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-106", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-08-27T21:36:52", "date_gmt": "2026-08-27T21:36:52", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK-1.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK-1.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "10x10_e2e_test_image_z9T8jK-1.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK-1.png"}, "id": 106, "link": "http://localhost:8889/?attachment_id=106", "media_details": {"file": "2026/08/10x10_e2e_test_image_z9T8jK-1.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-08-27T21:36:52", "modified_gmt": "2026-08-27T21:36:52", "ping_status": "closed", "post": 104, "slug": "10x10_e2e_test_image_z9t8jk-2", "source_url": "http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK-1.png", "status": "inherit", "template": "", "title": {"rendered": "10x10_e2e_test_image_z9T8jK"}, "type": "attachment"}, {"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=105"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/105", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/104", "id": 104, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-105", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-08-27T21:36:52", "date_gmt": "2026-08-27T21:36:52", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "10x10_e2e_test_image_z9T8jK.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK.png"}, "id": 105, "link": "http://localhost:8889/?attachment_id=105", "media_details": {"file": "2026/08/10x10_e2e_test_image_z9T8jK.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-08-27T21:36:52", "modified_gmt": "2026-08-27T21:36:52", "ping_status": "closed", "post": 104, "slug": "10x10_e2e_test_image_z9t8jk", "source_url": "http://localhost:8889/wp-content/uploads/2026/08/10x10_e2e_test_image_z9T8jK.png", "status": "inherit", "template": "", "title": {"rendered": "10x10_e2e_test_image_z9T8jK"}, "type": "attachment"}]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js:71:45

@adamsilverstein
adamsilverstein marked this pull request as ready for review August 5, 2026 22:24
@github-actions

github-actions Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org>
Co-authored-by: manzoorwanijk <manzoorwanijk@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Trunk switched @wordpress/dependency-group to 'never' mode, so the import
group headers in these files now fail lint.
Trunk replaced the appender button with a ghost block exposing
role=document, so the button locator no longer resolved and the test
timed out.
adamsilverstein and others added 15 commits October 5, 2026 16:43
The provider mixed the payload schema, attribute diffing and conflict
detection, op finders and the structural reject planning with the
persistence and notice code around them, so none of it could be read or
tested on its own. Move the pure functions into suggestion-mode/operations/
and have the structural apply and reject return a plan of block-editor
steps the provider dispatches, so the list indent/outdent rebuild no
longer needs a registry to reason about.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
The in-flight, resolved-this-session and withdrawn-anchor sets lived at
module scope, so two editor instances on one page shared them and a
decision in one could stop the other's note collector. Move them into
decision-state.ts behind a WeakMap keyed by the data registry: every hook
inside one editor still sees the same sets, and the module no longer
holds state tests have to reset between cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
Every write to the note comment (create, payload update, trash, decision
status) was an inline saveEntityRecord call in the provider, next to the
block-tree and notice code, so the storage shape leaked through the
whole file. Gather them in suggestion-store.ts behind a small typed
SuggestionStore, which is the one module a different backend would
reimplement. The size cap moves with the writes and is raised as an
error the callers already report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
With the pure logic, the persistence and the bookkeeping moved out, what
was left in provider.ts was two unrelated flows sharing one hook: the
suggester's create/update/delete and the reviewer's apply/reject. Give
each its own hook (use-suggestion-submission.ts, use-suggestion-
decisions.ts) and keep useSuggestionsProvider as the composition with
its return shape unchanged, so auto-save, the keyboards and the sidebar
keep working untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
Attribute suggestions need a durable home in post content like structural
and inline ones already have. The marker gains a pending-attributes type and
an after field any marker can carry, with the merge, sanitize and clear rules
in one module so the HOC, interceptor and decisions agree on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
The overlay entries map was the only place attribute proposals lived, and
the structural and inline paths had already moved their records into
content. Drop the entries store and keep what is genuinely per-session
coordination: bypass tokens, handler slots, the write queue, deferred
insertions, undo adoption, structural capture records and the title slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
The HOC diverted edits into React memory, so a proposal vanished on reload
and the author could not see it in Editing intent. Write it into the block's
metadata.suggestion instead, as a persistent change the undo stack owns, and
merge it for rendering in every intent so reviewers see what is proposed.

Every other consumer is pointed at the session module here so the module
graph resolves; their behavior moves off the overlay in the next commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
Direct dispatches (block switcher, multi-select) used to land in the overlay
entries map. Write them to metadata.suggestion.after like the HOC does, and
record structural ops on the session instead of the retired entries store.

The marker write runs after the revert so a drift that touched metadata
cannot clobber it, and a metadata clear landing on a block that never had
metadata reads as no proposal rather than an empty one. One fewer ref read
in the interceptor, so its eslint suppression count drops by one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
With proposals in content the marker is the record. Derive each marked
block's operations from the marker and the live tree, keep only session
bookkeeping in the hook, and write the note id back onto the marker so a
reload resolves it without a second note. A marker another author wrote is
left to that author, so a synced proposal never opens a note in our name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
…rkers

An attribute note is now anchored to the block marker's proposal, so the
collector trashes it when the proposal leaves content and the undo guard
no longer needs to withdraw it by hand: Ctrl+Z pops the marker write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
…this post

A marker's commentId and metadata.noteId are read from post content, which
any author of the post can edit, so an id found there is only a hint.
Auto-save now updates or trashes a note on that hint only when core-data
shows it as a pending note on the current post; otherwise it opens a fresh
note and leaves the named one alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
Apply lands the proposed values and drops the proposal in one update, and
puts both back if the decision fails to save; reject drops only the
proposal. Inline and structural decisions leave a co-resident proposal
where it is, and a rejected move keeps the attribute proposal that rode on
it as its own pending-attributes marker. SuggestionOperation now lives in
operations/payload.ts; the session only re-exports it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
Give the post title its own session slot, label a pending attribute change
in List View, and mount the session provider in place of the overlay one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
A decision on a structural note resolves the attribute ops it carried, so
clearing the marker drops a ride-along proposal instead of orphaning it as
a note-less pending-attributes marker. A proposal that returns after undo
or a revert is a new suggestion (auto-save forgets the block's note id and
fingerprint when its marker leaves), and redo restores an attribute note
like an inline one. An attribute-only note follows the block into a
structural retype so one note keeps both ops, and the undo guard keeps
the older attribute proposal when it withdraws the structural one. Edit
intent declines a write to a proposed attribute rather than landing it
behind the canvas. Store-level proposals stamp the history sequence like
HOC writes. A copied proposal is folded into a new insertion. Auto-save
waits for an unresolved hinted note instead of opening a second one, skips
a reloaded note that already holds the same ops, and only acts on notes
the current user authored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
adamsilverstein and others added 4 commits October 6, 2026 12:35
# Conflicts:
#	packages/editor/src/components/collab-sidebar/hooks.js
#	packages/editor/src/components/collab-sidebar/note-thread.jsx
#	packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js
#	packages/editor/src/components/collab-sidebar/utils.js
# Conflicts:
#	packages/editor/src/components/suggestion-mode/provider.ts
Trunk folded the floating notes sidebar into the canvas margin, dropped the
SIDEBARS list, and now expects calculateNotePositions to place the measured
cards while a neighbour is still unmeasured. Compare the active area to
ALL_NOTES_SIDEBAR directly, and skip an unmeasured card in the sweep instead
of holding the whole board back - it still sits out the hit test until the
next sweep places it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
adamsilverstein added a commit that referenced this pull request Oct 6, 2026
…the overview

Attribute suggestions now live on the block's metadata.suggestion marker
instead of an in-memory overlay, and the provider sits behind a
SuggestionStore with submission and decision hooks. Both landed in
#80433, so the overview's tables, diagrams and open findings described a
model the code no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USLfaxhwoVRBZ7QFP5XoeU
The block type was registered inside one test, so under CI's shuffled
test order a later test could create the block first. An unregistered
name makes createBlock fall back to core/missing, which is not
registered in this harness either, and the fallback recursed until the
stack overflowed. Register in beforeAll and unregister in afterAll.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NSEHmsqyXW8Yf62bJ3ckf
adamsilverstein and others added 7 commits October 7, 2026 10:34
Indenting a list item clones the source list for the new nested list,
so inside a suggested list the clone carried the list's pending-insert
marker and note link. The note then had two anchors: its summary moved
to the nested list, the parent insertion lost its note, and a second
decision appeared that could not resolve (#73411).

A block adopted without a note of its own, inside a pending insertion
or as a list indent's carrier, now drops the inherited suggestion state.
The test registers the real core-data store, and the reconciler reads
getCurrentUser(), whose resolver calls api-fetch against a jsdom with no
server. On a shuffled CI shard the failed request rejected after the test
ended and Vitest failed the run on the unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTRGFetxgiXPJC9a91DMTL
The test registers the real core-data store, and auto-save reads
getCurrentUser(), whose resolver calls api-fetch against a jsdom with no
server. A failed request can reject after its test ends, which fails a
shuffled CI shard on the unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTRGFetxgiXPJC9a91DMTL

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

No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core [Package] Block editor /packages/block-editor [Package] Core data /packages/core-data [Package] Editor /packages/editor [Type] Feature New feature to highlight in changelogs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggest mode: the note collector trashes a suggestion note with replies, taking the discussion with it

4 participants