Add an AI writing assistant to the Drafts widget - #439
Conversation
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
|
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.
|
| 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 onmissing, 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-suggestionschecked for a provider beforeedit_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 usedgenerate_result()+ an unguarded(string) $result->toText().includes/ai-copilot/client.phpwraps that call in a try/catch for exactly this reason. Switched togenerate_text()inside a try/catch, matching the comment scorer's shape, so an SDK throw lands on the same clean 502 as aWP_Errorinstead of a fatal.- List normalizer cast non-scalars; it skips them now.
4. House rules
- Removed the six
@since 0.26.0tags —AGENTS.md→ No 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 thedesktop_mode_drafts_suggestion_appliedaction, 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, anddocs/examples/drafts-ai-suggestions.md, indexed inexamples/README.md.
Green
npm run build, lint, typecheck clean. vitest 2461/2461 (252 files), phpunit 1659 tests / 4265 assertions OK.
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
left a comment
There was a problem hiding this comment.
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!
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
Graceful degradation (no provider configured)
The 💡 button is only rendered when
desktopModeConfig.aiAssistant.providerConfiguredis true, so a site without AI sees the widget exactly as before — no button, no empty state, no error. The server route mirrors this (returns503when no provider), as defence in depth.Implementation
desktop-mode/v1/draft-suggestions(read-only): reads the draft, callswp_ai_client_prompt()with a JSON-schema response, returns{ titles, excerpt, tags, categories, readiness }. Gated onedit_postand 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 canmanage_categories(mirrors Core — Authors may assign but not create); unknown categories are skipped. Gated onedit_post.src/plugins/drafts-widget/. Auto-refresh pauses while the panel is open so the AI round-trip isn't wiped by a poll.mb_substrto 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.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).