Skip to content

Add an AI writing assistant to the Drafts widget - #439

Merged
AllTerrainDeveloper merged 4 commits into
WordPress:trunkfrom
Alexismlg:feat/drafts-ai-suggestions
Jul 29, 2026
Merged

Add an AI writing assistant to the Drafts widget#439
AllTerrainDeveloper merged 4 commits into
WordPress:trunkfrom
Alexismlg:feat/drafts-ai-suggestions

Conversation

@Alexismlg

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in AI writing assistant to the Drafts widget. When an AI provider is configured (Settings → Connectors), each draft row gets a 💡 button that suggests a title, excerpt, tags and categories for the draft plus a short readiness check, and lets you apply the chosen suggestion straight onto the draft — without opening the editor.

Builds on the Drafts widget (#423).

What it does

  • Hover a draft → 💡 → a panel appears with:
    • Readiness — a one-line, evidence-based take on how close the draft is (and, only when genuinely present, what it still needs).
    • Title ideas / Excerpt / Tags / Categories — tap any suggestion to apply it to the draft (title/excerpt written, tags/categories appended). The row's title updates live; a toast confirms.
  • Categories are classified into the site's existing categories (passed to the model), not invented from scratch.

Graceful degradation (no provider configured)

The 💡 button is only rendered when desktopModeConfig.aiAssistant.providerConfigured is true, so a site without AI sees the widget exactly as before — no button, no empty state, no error. The server route mirrors this (returns 503 when no provider), as defence in depth.

Implementation

  • desktop-mode/v1/draft-suggestions (read-only): reads the draft, calls wp_ai_client_prompt() with a JSON-schema response, returns { titles, excerpt, tags, categories, readiness }. Gated on edit_post and a configured provider.
  • desktop-mode/v1/draft-apply: writes the accepted title / excerpt / tag / category onto the post. New categories are only created for users who can manage_categories (mirrors Core — Authors may assign but not create); unknown categories are skipped. Gated on edit_post.
  • UI lives in the existing src/plugins/drafts-widget/. Auto-refresh pauses while the panel is open so the AI round-trip isn't wiped by a poll.
  • Content is truncated with mb_substr to avoid splitting multibyte characters; the readiness prompt is strict/evidence-based to avoid the model inventing issues.

Testing

  • npm run typecheck, npm run lint, npm run test:js (drafts widget: 12 tests) all pass.
  • New phpunit coverage for draft-apply (tests/phpunit/tests/widgetDrafts.php, now 8 tests / 22 assertions): title+excerpt write-back, tag append, and the category-creation capability gate (editor creates; author can assign an existing category but cannot create a new one; subscriber is forbidden).
  • Verified end-to-end in wp-env with a real provider: suggestions + apply + readiness + category classification all work; with no provider the button is absent and the endpoint 503s.
  • CI never needs an API key — the deterministic parts are what's tested; the LLM call itself is exercised locally only.

Alexismlg and others added 2 commits July 28, 2026 20:16
Adds an opt-in ✨ action to each row of the Drafts widget that, when an
AI provider is configured (Settings → Connectors), suggests a title,
excerpt, tags and categories for the draft plus a short readiness check —
and applies the chosen suggestion straight onto the draft.

- New REST route `desktop-mode/v1/draft-suggestions` (read-only): reads
  the draft, calls `wp_ai_client_prompt()` with a JSON schema, returns
  { titles, excerpt, tags, categories, readiness }. Gated on the user
  being able to edit the post AND a provider being configured; returns a
  clean 503 otherwise.
- New REST route `desktop-mode/v1/draft-apply`: writes the accepted
  title / excerpt / tag / category onto the post. New categories are
  only created for users who can manage categories (mirrors Core —
  Authors may assign but not create); unknown categories are skipped.
- UI: a hover-revealed 💡 button (only rendered when an AI provider is
  available) opens a panel with the readiness summary and tap-to-apply
  suggestions. Auto-refresh pauses while the panel is open so the AI
  round-trip isn't wiped by a poll.

Degrades cleanly: with no provider the button never renders and the
widget behaves exactly as before.

Categories are classified into the site's existing terms (passed to the
model); the readiness check is strict/evidence-based to avoid inventing
issues. Content is truncated with mb_substr to avoid splitting multibyte
characters.

Adds phpunit coverage for draft-apply (title/excerpt/tags write-back and
the category-creation capability gate).
Review follow-up on the AI writing assistant. The feature itself is
sound; this brings it in line with the repo's rules and closes the
coverage gap.

UI — every control in the feature was a bare `<button>`. Both row
actions (Trash, Suggest) and every tap-to-apply suggestion are now
`<wpd-button variant="ghost">`, tuned through the documented
`--wpd-button-*` custom properties and the `button` shadow part rather
than reimplemented. The readiness verdict is a `<wpd-notice>` that
tone-codes itself (success when nothing is missing, warning otherwise),
the load state is a `<wpd-spinner>`, and a failed round-trip degrades to
an error notice instead of bare text. The suggestion buttons use the
component's `busy` state while the write is in flight and lock
themselves afterwards, so an impatient double-click can't apply the
same suggestion twice.

The Trash button was converted alongside the new one — leaving one bare
`<button>` next to a `<wpd-button>` in the same row would have shipped
two different focus rings and two different disabled looks.

A11y — the Suggest button is now a real disclosure (`aria-expanded` /
`aria-controls`), the panel is a labelled `role="group"`, and the
loading region is `aria-live="polite"` so the result is announced.

Theming — the panel's hardcoded #7c3aed / #a78bfa / #22c55e are now
`--wpd-accent` / `--wpd-success-fg` with fallbacks, matching how the
Trash button already read `--wpd-danger`.

Server —

- Dropped the `@since 0.26.0` docblock tags (AGENTS.md: no
  version-history annotations).
- `/draft-suggestions` checked for a provider before checking
  `edit_post`, so the 503-vs-403 split told an unauthorized caller
  whether the site had AI configured. Capability first now.
- The AI call went through `generate_result()` + an unguarded
  `toText()`, which throws. Switched to `generate_text()` inside a
  try/catch — the same call shape the comment scorer uses — so an SDK
  throw lands on the same clean 502 as a WP_Error.
- Extracted the prompt, schema and list-normalizer into named functions
  and made every decision point hookable:
  `desktop_mode_drafts_ai_instructions`, `…_ai_schema`,
  `…_ai_content_limit`, `…_ai_suggestions`, plus the
  `desktop_mode_drafts_suggestion_applied` action.
- The list normalizer now skips non-scalars instead of casting them.

Tests — the PR added phpunit coverage for `draft-apply` but none for
`draft-suggestions`, and no JS coverage at all.

- vitest 12 → 25: no button without a provider, the disclosure
  contract, the fetch payloads, every suggestion group rendering as a
  `<wpd-button>`, readiness tone flipping on `missing`, apply
  write-back + row update + toast, apply failure staying re-tryable,
  suggestions failure degrading to a notice, close-on-second-click, and
  the refresh suppression while a panel is open.
- phpunit 8 → 17: the 403-before-503 ordering, the 503 with no
  provider, title sanitization, empty-title no-op, the applied action's
  payload, prompt assembly (title + stripped content + existing
  categories), the content-limit filter, instruction/schema
  filterability, and list normalization.

Docs — hooks reference gains a Drafts-widget section documenting both
REST routes and the five new hooks; architecture.md records the
read-only/write route split and the provider gate; new
`docs/examples/drafts-ai-suggestions.md`, indexed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s
@AllTerrainDeveloper

AllTerrainDeveloper commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks! That's a good feature, and the read-only/write route split is the right call. Two things blocked it as-is, and I've pushed the fixes to this branch (987acd9) rather than sending it back — details below so nothing lands silently.

1. wpd-* components (blocking)

Every control in the feature was a bare <button>. Per AGENTS.mdUse wpd-* components, not raw HTML controls, these carry the framework's keyboard-nav, focus-management, theming-token and a11y plumbing for free; raw HTML doesn't.

Converted:

Was Now
<button class="dm-drafts__spark"> <wpd-button variant="ghost">
<button class="dm-drafts__suggest-item"> (titles, excerpt) <wpd-button> tuned to a list row
<button class="dm-drafts__suggest-tag"> (tags, categories) <wpd-button> tuned to a pill
panel.textContent = 'Thinking…' <wpd-spinner> + label
.dm-drafts__readiness div <wpd-notice>, tone-coded success / warning off missing
error text <wpd-notice tone="error">

Styling goes through the documented --wpd-button-* custom properties and the button shadow part, not a reimplementation. The apply buttons now use the component's busy state while the write is in flight and disabled after — an impatient double-click can't apply the same suggestion twice, which the old code allowed.

I also converted the pre-existing Trash button in the same row. Leaving one bare <button> beside a <wpd-button> would have shipped two different focus rings and two different disabled looks in the same 26px gutter. Shout if you'd rather I split that out.

A11y — the Suggest button is now a real disclosure (aria-expanded / aria-controls), the panel a labelled role="group", the load region aria-live="polite".

Theming#7c3aed / #a78bfa / #22c55e were hardcoded. Now --wpd-accent / --wpd-success-fg with fallbacks, matching how the Trash button already read --wpd-danger.

2. Tests (blocking)

The phpunit coverage for draft-apply was solid — the category-creation capability gate in particular. But draft-suggestions had none, and there was no JS coverage at all for ~260 new lines of UI.

  • vitest 12 → 25 — absent button without a provider, disclosure contract, fetch payloads, every group rendering as <wpd-button>, readiness tone flipping on missing, apply write-back + row update + toast, apply failure staying re-tryable, suggestions failure degrading to a notice, close-on-second-click, refresh suppression while the panel is open.
  • phpunit 8 → 17 — the ordering fix below, the 503 path, title sanitization, empty-title no-op, the applied action's payload, prompt assembly, the content-limit filter, instruction/schema filterability, list normalization.

3. Correctness

  • Permission ordering/draft-suggestions checked for a provider before edit_post, so the 503-vs-403 split told an unauthorized caller whether the site had AI configured. Capability first now; the 403 is identical either way.
  • toText() can throw — the route used generate_result() + an unguarded (string) $result->toText(). includes/ai-copilot/client.php wraps that call in a try/catch for exactly this reason. Switched to generate_text() inside a try/catch, matching the comment scorer's shape, so an SDK throw lands on the same clean 502 as a WP_Error instead of a fatal.
  • List normalizer cast non-scalars; it skips them now.

4. House rules

  • Removed the six @since 0.26.0 tags — AGENTS.mdNo version-history annotations in docs or comments. Git history is the changelog.
  • Hooks. Nothing in the feature was extensible. Added desktop_mode_drafts_ai_instructions, …_ai_schema, …_ai_content_limit, …_ai_suggestions, and the desktop_mode_drafts_suggestion_applied action, with the prompt/schema/normalizer extracted into named functions.
  • Docs. Two new REST routes are public surface. Added a Drafts-widget section to hooks-reference.md (both routes + the five hooks), an architecture.md paragraph on the route split and the provider gate, and docs/examples/drafts-ai-suggestions.md, indexed in examples/README.md.

Green

npm run build, lint, typecheck clean. vitest 2461/2461 (252 files), phpunit 1659 tests / 4265 assertions OK.

AllTerrainDeveloper and others added 2 commits July 29, 2026 13:33
Three contrast failures on a dark glass widget card, and the loading
indicator was unreadable at the size it was being used.

**Notices.** `<wpd-notice>` defaults its text to `--wpd-fg` (#1d2327)
over an 8%-alpha tone wash — a light-surface pairing. A widget card is
glass over an arbitrary wallpaper, so dark-on-dark collapsed to
unreadable. The new `dm-drafts__notice` class re-points the component's
documented color surface at `currentColor`: the text tracks whatever the
card already proved legible, and the tone survives in the accent stripe
and icon, each mixed toward `currentColor` so it reads either way. Also
trims the banner-scale 10/14px padding to panel scale.

**Applied suggestions.** These used the component's `disabled` for the
"already applied" lock, which dims to 50% opacity — halving contrast
exactly where we could least afford it, and on top of a flat #008a20
green that vanished into the card. Now `aria-disabled` plus an
`is-applied` class (the click guard reads the class), so the control
stays fully opaque. The label sits at the card's own text color — the
one color the card guarantees is readable — and the state is carried by
a currentColor wash, a tinted border and a ✓. Shape and fill, not hue.

**Accent text.** `--wpd-accent` / `--wpd-danger` neat are mid blue and
mid red; used as TEXT on a dark card they sink. Introduced
`--dm-drafts-accent` / `--dm-drafts-danger`, both mixed toward
`currentColor`. Backgrounds keep the neat token — a low-alpha wash has
no contrast problem.

Also: the readiness "missing" list was a flex column, which suppresses
list markers and made four bullet points read as four orphaned grey
lines. Now block + disc. Nudged the hint and group labels up from 0.55 /
0.6 opacity, and the pill border from 20% to 30%.

**`<wpd-spinner preset="inline">`** — a new preset, and the first one
that isn't a re-tuning of the mark-and-rings artwork. That artwork
carries four concentric strokes plus a 4-path glyph in a ~150-unit
viewBox and needs ~40px to be recognisable; at the 18px the panel was
using it, every stroke lands under a physical pixel and the W greys out
into a smudge. `inline` renders one faint track ring and one rotating
arc on a 24-unit viewBox — crisp at 14px. It defaults to 16px rather
than 48px, and to `currentColor` rather than the admin theme color, so
it tints itself from the text it interrupts and can't lose contrast
against a surface the component knows nothing about. `sp1` / `a1` still
tune tempo and arc length; the disc/mark/ring knobs are inert.

Tests +5: the inline preset drops the mark and the concentric rings,
keeps role/aria-label and the tempo knob, appears in the exported preset
registry, survives switching to and from another preset, and the drafts
panel asks for it by name. Plus a regression test that panel notices
carry the contrast-override class — a class that is easy to drop and
whose absence no ordinary DOM assertion would catch.

Docs: spinner example gains an "Inline: spinners that sit beside text"
section explaining when the mark stops working and why the preset
defaults differ; preset tables and the components reference updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s

@AllTerrainDeveloper AllTerrainDeveloper left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Image

I have tweaked a bit the new widget and adecuated it to what we are expecting, have you checked that your LLM is actually consuming the agents.md? seems It's not quite following it.

But anyway, good feature! And the good thing is that peopl who don't have a connector setup won't see it so it's not annoying

Thanks!

@AllTerrainDeveloper
AllTerrainDeveloper enabled auto-merge (squash) July 29, 2026 11:37
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 070198e into WordPress:trunk Jul 29, 2026
6 checks passed
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