Skip to content

Suggest mode 1/9: editor intent (edit/suggest/view) and experiment gate - #80427

Open
adamsilverstein wants to merge 72 commits into
trunkfrom
suggest/intent
Open

adamsilverstein wants to merge 72 commits into
trunkfrom
suggest/intent

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

Part of #73411

What's in this PR

Adds the session-scoped editor intent state (private setEditorIntent
action / getEditorIntent selector), the Edit / Suggest / View switcher in
the editor options menu, Google Docs convention keyboard shortcuts, and a
read-only view intent via isPreviewMode. The Suggestion Mode experiment
flag and the gate helpers (isSuggestionModeEnabled / useCanSuggest) land
here so every later layer gates on the same predicate. No suggestion
capture or storage yet.

The view intent's read-only contract is enforced here too, in the layer
that promises it: preview mode is added to the memoized insertion
selectors' dependants so flipping it at runtime invalidates their cached
answers, the writing flow's cross-block input handling bails out while
the canvas is a preview, and the Block Inserter toggle is disabled (an
open inserter is closed on the way in). This was originally #81661 on top
of the stack and moved down here so it can be reviewed next to the intent
it belongs to.

Screenshot

The Edit / Suggest / View switcher in the editor Options menu, with the Google Docs style shortcuts alongside each mode:

Mode group in the editor Options menu showing Editing, Suggesting and Viewing

image

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.

Adds the session-scoped editor intent state (private setEditorIntent
action / getEditorIntent selector), the Edit / Suggest / View switcher in
the editor options menu, Google Docs convention keyboard shortcuts, and a
read-only view intent via isPreviewMode. The Suggestion Mode experiment
flag and the gate helpers (isSuggestionModeEnabled / useCanSuggest) land
here so every later layer gates on the same predicate. No suggestion
capture or storage yet.
@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Size Change: +1.92 kB (+0.02%)

Total Size: 7.91 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 478 kB +31 B (+0.01%)
build/scripts/editor/index.min.js 582 kB +1.89 kB (+0.33%)

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
@github-actions

github-actions Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Flaky tests detected in 5df3684.
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/33116177758
📝 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:13:31", "date_gmt": "2026-08-27T21:13:31", "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:13:31", "modified_gmt": "2026-08-27T21:13:31", "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:13:31", "date_gmt": "2026-08-27T21:13:31", "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:13:31", "modified_gmt": "2026-08-27T21:13:31", "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

# Conflicts:
#	packages/editor/src/components/global-keyboard-shortcuts/index.js
#	packages/editor/src/store/private-selectors.js
@adamsilverstein
adamsilverstein marked this pull request as ready for review August 5, 2026 22:24
# Conflicts:
#	packages/editor/CHANGELOG.md
#	packages/editor/src/store/private-actions.js
#	packages/editor/src/store/private-selectors.js
…spec

The new @wordpress/dependency-group rule runs in 'never' mode, so the
header comment left in this spec still failed lint after the last pass.
The guard skipped the mode snackbar and a11y announcement when the
previous intent was undefined, but the reducer defaults the slice to
`edit` and the selector falls back to `EDITOR_INTENT_EDIT`, so that value
never occurs. The comment promised boot silence for any initial intent
while only `edit` was ever silent, which is a trap for the next reader.

Keep the equality check, which is what actually suppresses the notice, and
say so.
New React files must use .tsx and new source should be TypeScript, so the
intent switcher, the Suggest mode gate and constants, and their tests move
to .ts/.tsx with real annotations instead of JSDoc types.

MenuItemsChoice's onHover is typed as required even though the component
defaults it to a noop and its own usage example omits it; typing the first
TypeScript consumer surfaced that, so mark the prop optional.
@adamsilverstein
adamsilverstein requested a review from ciampo October 1, 2026 22:50
@jasmussen

Copy link
Copy Markdown
Contributor

Testing just this one in isolation, I get the same error noted here. Let me know if I'm doing something wrong.

adamsilverstein and others added 2 commits October 7, 2026 14:55
Trunk's Menu now throws in development builds when a Menu.Group sits
inside a Menu.RadioGroup (#83792), which crashed the editor as soon as
the Options menu opened. Put the Mode label and radio items directly in
the RadioGroup, the same shape trunk gave ModeSwitcher, and cover it
with a jsdom test since production e2e builds never hit the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTRGFetxgiXPJC9a91DMTL
# Conflicts:
#	packages/editor/CHANGELOG.md
@adamsilverstein

Copy link
Copy Markdown
Member Author

Testing just this one in isolation, I get the same error noted here. Let me know if I'm doing something wrong.

this should be fixed now @jasmussen - thanks for letting me know it was broken!

@jasmussen

Copy link
Copy Markdown
Contributor

Took it for a spin:

state

Bolding doesn't create a note. I understand that's part of a separate PR in the stack, that's fine, the focus of this one is the dropdown, this one:

image

I think it makes sense insofar as we refactored appearance and panels to make room for this. If that becomes controversial once landed, we can invert that: restore appearance toggles direcly, and put suggest modes inside a flyout. I prefer what this branch does, but I always want to think of contingencies.

The larger motivation: we are still doing that careful dance of adding features but balancing prominence with readiness. The truth is that longer term, edit/suggest/view probably deserve their own dropdown, a la this:

image

But that's not near term.

One thing though: we could start without view. There's an aspect of user roles in WordPress, if you're just a contributor you might have view. Or if in the future we build a "share" link, we could assign roles to the shared URL. That's all part of the collaborative future, and what I'm suggesting is that if "view" in its present state is low value, we can postpone it.

@adamsilverstein

Copy link
Copy Markdown
Member Author

Thanks as always for testing and the feedback @jasmussen.

One thing though: we could start without view. There's an aspect of user roles in WordPress, if you're just a contributor you might have view. Or if in the future we build a "share" link, we could assign roles to the shared URL. That's all part of the collaborative future, and what I'm suggesting is that if "view" in its present state is low value, we can postpone it.

Interesting idea. Viewing mode is fairly distinct from suggesting mode and removing it might significantly reduce complexity of the overall stack. Let me research that further and see what the impact of removing it would be.

@adamsilverstein

Copy link
Copy Markdown
Member Author

I wanted to see how much it would actually save so I asked Claude to review:

Claude went through the stack to size up what View costs:

View accounts for roughly 700 of the stack's ~53k changed lines, and only about 200 of those are production code. Almost all of it lives in this first PR: the preview-mode toggle, guards that refuse sidebar edits and undo/redo while viewing, the View branch of the code editor check, the menu item and shortcut, plus a 322-line e2e spec.

The bigger win is lower risk rather than less code. View is read-only by opt-out, so every path that can change the post has to remember to check for it. That has already surfaced one gap at a time (canvas, code editor, undo/redo, sidebar settings). It is also the only reason the stack touches block-editor read-only behavior (isPreviewMode checks in use-input.js and the insert cache in store/utils.js), which changes behavior for everyone.

Edit/Suggest still need the intent store, switcher, and code editor masking, so those stay. If View comes back later, deriving it from roles or a share link (the server deciding what a user can do) seems like a better fit than a self-selected toggle the client has to enforce.

If others agree this is a good idea, I'll pull View out of this PR and cascade the change through the stack.

adamsilverstein and others added 13 commits October 10, 2026 19:20
Only the post status was locked while suggesting, so the excerpt,
featured image, slug, terms, meta and every other post setting changed
the saved post directly (tyxla on #73411). Most sidebar panels and
plugins write the entity through `useEntityProp`, which never passes
through `editPost`, so the status lock could not catch them.

Guard the current post at three seams: `editPost` sorts its edits into
content (passes), proposable (Phase 2) and refused; a wrap of the
core-data `editEntityRecord` action refuses direct writes to the
current post while suggesting; and `savePost` strips every post-level
edit from a save made while suggesting, so an edit staged in Editing is
never persisted from Suggesting. Settings that cannot be suggested are
shown read-only with the reason as their description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Phase 1 refuses every post-level edit while suggesting; the excerpt,
featured image and slug are fields a suggester should be able to
propose, the way the title already is.

Generalize the title proposal into post field proposals held in the
editor store rather than the session, so `editPost` can divert a
proposable edit into a proposal and `getEditedPostAttribute` can show
the proposed value while suggesting: every panel that reads the post
through the editor store shows the proposal with no change of its own.
The title moves onto the same path. Each proposal is saved as its own
note with a `post-attribute-set` op; accept applies it past the guard,
reject drops it, and undo withdraws the newest one. The note names the
field and the summary shows before and after (thumbnails for the
featured image).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Categories, tags and custom taxonomies changed the saved post directly
while suggesting. Treat each of the post type's taxonomies (by the
`rest_base` its term ids are edited under) as a proposable field, so
picking terms becomes one note per taxonomy whose summary lists the
terms added and removed.

Creating a term is a real write to the taxonomy that no reviewer could
take back, so the term pickers stop offering to create one while
suggesting; only existing terms can be suggested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Post meta written through `editPost` (the classic pattern for a plugin's
meta field) changed the saved post directly while suggesting. Treat a
meta key the post's REST record carries as proposable, one note per
key, shown in the plugin's field while suggesting. Plugins that write
the entity directly stay refused by the Phase 1 guard.

A reviewer's accept writes whatever the note holds, so the server now
validates every post-level op before a note is saved: only the fields
Suggestion mode proposes, a REST-exposed taxonomy of the post type, or
a meta key registered with `show_in_rest` that the suggester could edit
(`edit_post_meta`). Anything else is refused with a 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Trashing the post is not a change a reviewer could accept or reject, so a
suggester must not be able to do it. The sidebar button is disabled with
the reason as its description, the post actions menu stops offering Trash
and the other delete or site-setting actions, and the store refuses
trashPost and a core-data deleteEntityRecord of the current post with a
refusal notice, so plugins and the console are covered too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
The template summary in the post editor edits site settings (posts per
page, the site's discussion default) and the posts page title (the blog
title). None of them belong to the post, so a suggestion cannot propose
them, yet they were written directly while suggesting.

The guard now refuses editEntityRecord on the site entity and on the posts
page, and refuses saveEntityRecord and saveEditedEntityRecord on the site
entity so a setting staged in Editing is not saved from Suggesting either.
The rows, legacy and DataForm, are read-only while suggesting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
The trashPost mock gains the intent selector the action now reads, and
two cases cover the refusals: trashPost and deleteEntityRecord of the
current post send no DELETE while suggesting, and a site entity edit is
dropped with the refusal announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Post field proposals live in the editor store, which a reload empties, so
editing a field again after a reload opened a second note beside the
pending one, and editing it back to the post's value left the note behind.

The current user's pending post-level notes now restore their proposals
when the notes load (the note's before is the baseline), linked to their
note. The field shows the proposal again while suggesting, the auto-saver
updates that note instead of creating one, and going back to the post's
value trashes it. Undo after a re-edit returns to what the note holds
rather than withdrawing a suggestion made in an earlier session. Other
authors' notes are never restored, and a decision on one of them no
longer clears the user's own proposal for the same field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
The term pickers hid "Add Category" and "Create:" while suggesting,
because creating a term is a real write to the taxonomy. A suggester
could only pick terms that already existed.

A new term now rides on the terms proposal as { name, parent? } beside
the term ids, and nothing is written to the taxonomy while suggesting.
The pickers show it like a proposed existing term (a chip, a checked
checkbox), getEditedPostAttribute keeps returning term ids only, and the
note summary reads "New tag: foo". Accepting creates the term as the
reviewer, reusing one with the same name that appeared meanwhile, then
assigns it; when the reviewer may not create terms the accept fails with
a notice and the note stays pending. Rejecting writes nothing.

The server validates the entries: each is a term id or a new term with a
sanitized, non-empty name, and a parent only for a hierarchical taxonomy,
as an existing term of it. The suggester needs no capability to create
terms, since nothing is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
getEditedPostAttribute is public, and while suggesting it returns a
field's pending suggested value rather than the post's. That is what lets
every panel and plugin show the proposal, so it stays, but its docblock
now says so and where to read the post's own value. The architecture doc
and the changelog cover it along with new term suggestions, restoring
proposals after a reload, and the trash and site settings locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
The code editor guard reads saved content back for pending markers.
Markers now carry one class per kind, so the guard has to find all three
and stop treating the old single class as a marker.

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

SUGGESTION_MARKER_CLASS becomes SUGGESTION_MARKER_CLASSES, the store's
copy of the three class tokens, so the code editor guard still finds a
pending marker of any kind and no longer matches the retired class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GLh3wdWJSgU3NLobNDk6x
Brings the branch current with trunk so the PR stops reporting as
conflicting and gets a real CI run. The only conflict was the editor
changelog: the intent entry stays under New Features in Unreleased,
trunk's new Enhancements entry follows it.

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

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] Editor /packages/editor [Type] Feature New feature to highlight in changelogs.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants