Redesign the native Comments window as a two-pane conversation view - #441
Conversation
Reworks the opt-in native Comments window from a flat moderation table into a two-pane conversation view: a rail of top-level conversations on the left, and the selected thread's full nested reply chain on the right with per-message actions (reply, edit, approve/unapprove, spam, trash) and a docked reply composer. - Fixes one-level threading: renders the entire nested chain, built client-side from a single thread fetch (fetchThread; approve + hold legs merged since wp/v2/comments rejects a multi-value status). - Every message, including nested replies, carries always-visible actions instead of the previous keyboard-only reply/edit. - Rendering + interaction move to src/comments-window/conversation.ts; index.ts becomes a thin registry entry; rest.ts gains fetchThread. Opt-in and OFF by default (nativeCommentsEnabled untouched) so the default experience is unchanged; only users who enable the native window under Settings -> Features see the redesign. No AI. Capability model unchanged and server-enforced (edit_comment per row); all mutations reuse the existing gated REST routes.
Data layer for scoping the Comments window to one post: a cross-bundle shared store (set by the URL remap, read + subscribed by the renderer) holding the pending post id, and an optional 'post' filter on fetchComments.
- Pencil in the conversation header opens the post's block editor as a Desktop Mode window (link-interceptor path). - Per-post filter: rail scoped to the post + 'On: <title> / Show all' banner, All-tab default, live re-scope via the store subscription (native windows render once, so an already-open window re-reads here). - Announce the window's content identity so the window-links connection spline to the post's editor is drawn (parity with the classic iframe).
…ads ?p= - The Related menu now consults tryNativeUrlRemap (like the shell's link interceptor) so a related link opens the opted-in native window instead of a chromeless iframe of the classic admin page. - The Comments remap's onMatch parses edit-comments.php?p=<id> and stashes the post filter, so 'Comments (N)' lands on the native window scoped to that post.
- fetchThread fetches all four moderation statuses (approve/hold/spam/ trash) so a thread renders in full on the Spam and Trash tabs, not just approved/pending. - Post filter is consume-once (takeCommentsPostFilter reads + resets without notifying), so a plain reopen (taskbar, desktop icon, openNativeWindow) that skips the URL remap starts unfiltered instead of reusing a stale post scope. - loadRail guards against out-of-order responses with a sequence token. - The post-filter store subscription is dropped on desktop-mode-window-closed, not just lazily on the next notify. - Tabs toggle aria-selected (not only an is-active class). - Remove dead template nodes (tab-count, rail-foot, new-pill); clearer empty-state copy when the rail is scoped to a post.
<wpd-avatar>: the tile carries a transform (hover tilt + scale) on top of border-radius + overflow:hidden, so the circular clip and the image edge round to different subpixels and leave a hairline crescent of tile background around the rim. Bleed the image 1px past the tile on every side so the seam falls outside the clip. <wpd-relative-time>: add `compact` for dense list cells — "now", "5m", "3h", "2d", then a short date once the age passes a week, where the relative reading stops being useful in a narrow column. Formats through Intl.RelativeTimeFormat with style: 'narrow', so the abbreviations are the ones the viewer's locale actually uses rather than English initials. The absolute timestamp stays in the title either way. Adds the component's first test file, covering the new mode, the MySQL-style-as-UTC parsing, and the malformed-input path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s
Correctness - fetchThread was four status legs concatenated approve/hold/spam/trash, so replies under a parent rendered approved-first rather than chronologically. Now one request: status=any is the vocabulary WP_Comment_Query understands for "no status clause", and the result is sorted by date_gmt. - statusForTab emitted comma lists. wp/v2/comments declares `status` as a string with sanitize_key, which strips the comma rather than erroring — so "approve,hold,spam" reached WP_Comment_Query as the nonsense status "approveholdspam" and the Mine tab silently returned nothing. Single values only now, and the comment records what actually happens. - The All tab mapped to `approve`, hiding every unmoderated comment — including on the per-post scoped view, which opens on that tab precisely to show the post's whole thread. Now `all`. - The rail client-filtered top-level comments out of a mixed page, so a page that happened to hold only replies rendered an empty rail while the badge still counted them. Ask the server for parent=0 instead, and add a Load more footer driven by X-WP-TotalPages. - Inline edit had no re-entrancy guard: a second Edit click stacked a second editor and button bar, and Cancel tore down only one. - Composer Send and inline-edit Save reset in a finally. The success path normally replaces them, but renderConvo's race guard can bail out when the user picks another thread mid-flight, stranding a disabled control. - Re-picking the conversation already on screen is a no-op. It used to re-fetch and repaint, discarding scroll position, a half-written reply, and any open inline editor. Cost - fetchThread inherited the rail's _fields, dragging desktop_mode_replies_ count (a get_comments() COUNT per row) across up to 100 rows on every thread click and every moderation action. It now sends its own narrow set. The window default drops spam_score, link_count, akismet and ai_verdict too — computed per row, rendered by nothing. - Thread and rail reloads run concurrently rather than serially. Accessibility - aria-live off the conversation pane, which is wholly replaced on every selection; a role="status" node carries action results instead. - role="listbox" with button children overrode the button role. Now role="list" + listitem wrappers, with aria-current on the selected row (aria-selected is not valid on a button). - Text equivalents for the colour-only status dot and the reply count. wpd-* kit - <wpd-tabs>/<wpd-tab> back in place of hand-rolled role="tab" buttons, which had lost roving tabindex, arrow-key nav and the focus ring. - <wpd-textarea> for the composer and inline editor, <wpd-button> for every action, <wpd-avatar> for commenter avatars, wpdConfirm before Spam and Trash, <wpd-badge> for the five status/count pills, <wpd-empty-state> for placeholders and errors, <wpd-spinner> for loading, <wpd-icon> for the pencil, <wpd-relative-time> for both timestamps. - The avatar previously pointed applyAvatarSrc at a bare <img>. That helper removes `src` when an address has no registered Gravatar so <wpd-avatar> can fall back to initials; against an <img> it just left an empty circle. - Per-message actions render as link buttons with pipe separators, matching wp-admin's own comment row actions. Also - Deleted the orphaned intro dialog, two uncalled REST helpers, the insights flyout and keyboard-shortcuts markup, and the dead legacy table/toolbar/pager/drawer CSS. - The Settings > Features description still advertised an 8-second undo, an insights drawer, a spam confidence score and j/k/a/s/d shortcuts, none of which this surface has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s
There was a problem hiding this comment.
This is freaking amazing. I've been testing and reviewing and I found a few things, if you don't mind for the sake of simplicty and velocity I'll do those:
- I'll use links button there
- I've changed the header to have the same external icon as Gutenberg (and the link is now clickable so no need to add extra button
-
Cliking on a conversation that is opened, reloads it.
-
Improved loading speed by adding promises.all
-
We are not using the
wpd-web components.
There is a huge list of wpd components that are reusable to keep UI consistent:
OS Settings -> Components serves as an example library (Kind of story book self maintained)
Please if you think I've made something worse or out of your vision, let me know and we will gladly check it
A comment posted an hour earlier read "3 hours ago" at UTC+2 — off by
exactly the viewer's offset.
`parseDatetime` took the presence of a "T" as proof the value was fully
qualified and handed it to `Date` as-is. ECMAScript parses a date-time
with no timezone designator as LOCAL, so every `*_gmt` field in ISO form
("2026-07-28T22:12:34" — wp/v2 emits no Z) was read as local time. Only
the MySQL space-separated branch ever got its `Z` appended.
Test for a real designator instead — `Z` or a numeric offset — and treat
everything else as UTC, which is the documented contract. The check is
scoped to the time portion so the date part's own hyphens can't be
mistaken for a "-04:28" offset.
This also fixes the field-choice trap the old behaviour hid. WordPress
emits `date` (site timezone) and `date_gmt` (UTC) in an identical shape,
so the string alone cannot say which it is; callers must pass the `*_gmt`
variant. The Posts window was passing `date`, which the old parser
happened to read as local — right only when the viewer's timezone
matched the site's. It now passes `date_gmt`, matching the sort
comparator on the same column.
Unaffected: the Recycle Bin (space-separated `current_time('mysql',
true)`, always took the UTC branch) and the games views (ISO with `Z`).
Adds regression coverage for the wp/v2 `date_gmt` shape, an explicit
numeric offset, and the date-only case. The suite passes under both
TZ=UTC and TZ=Europe/Madrid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sKW1mu1SGg5k9oSHgxv5s
|
Thank you so much for testing, reviewing, and jumping in on the polish — really appreciate it! 🙏 All five make sense to me and none of them go against the vision, quite the opposite:
Please go ahead with all of them — nothing feels worse or out of vision. Thanks again for the collaboration! 🚀 |
Summary
Reworks the opt-in native Comments window from a flat moderation table into a two-pane conversation view, and wires it into the surrounding desktop (per-post scoping, editor jump, connection spline).
Built entirely on existing Desktop Mode / wp-admin tokens and the
<wpd-*>kit — no new visual vocabulary, and no AI.Why
Testing the native window surfaced three limitations in the table UI, all fixed here:
wp-adminrather than a Desktop Mode app.Opt-in, default unchanged
Opt-in and OFF by default (
nativeCommentsEnabled = false, untouched). Users who don't opt in still get the classicedit-comments.phpscreen. This PR only changes what's inside the native window, which appears only when a user enables it under Settings → Features → "Use the native Comments window".Desktop integration (added on top of the base redesign)
edit-comments.php?p=<id>(WP's "comments on this post") now opens the native window scoped to that post — the rail shows only that post's conversations with an "On:<title>/ Show all" banner. The Comments URL remap parses?p=inonMatchand stashes it in a small cross-bundle store the renderer reads (mirrors the user-edit target pattern); a store subscription re-scopes an already-open window, since native windows render once.tryNativeUrlRemap()first (as the shell's link interceptor already does), so a related link opens the opted-in native window instead of a chromeless iframe of the classic page. This is a small framework-level change indesktop.ts— it benefits every native window (Posts/Users/…), and is the "revisit when native windows accept deep-link hints" the old comment there called for (the hint is now the remap'sonMatch). Flagging for review as it touches shared code.wp.desktop.relations.set()so the window-links spline to the post's editor is drawn — parity with what the classic iframe got from the chromeless bridge.Implementation
src/comments-window/conversation.ts(new) owns all rendering + interaction;index.tsis a thin registry entry.src/comments-window/rest.tsgainsfetchThread()(approve + hold merged, sincewp/v2/commentsrejects a multi-valuestatus) and an optionalpostfilter onfetchComments.src/comments-window/post-filter.ts(new) — the cross-bundle post-filter store.src/desktop.ts— the Comments remaponMatch+ the Related-menu remap consult.includes/comments-window/window.phpemits the two-pane shell;assets/css/comments-window.cssadds the layout.bulkModerate,postReply,updateCommentContent) wholesale — transport + capability gates unchanged.Security / capabilities
No change to the capability model. The Edit button renders only when the server reports
current_user_can( 'edit_comment', $id )(desktop_mode_can_edit), and every mutation re-checks the capability server-side.Commit layout
fetchCommentspostparam.?p=.fetchThreadcovers all four statuses (spam/trash threads render); consume-once post filter (no stale scope on a plain reopen);loadRailsequence guard; post-filter subscription dropped on window close; tabs togglearia-selected; dead template nodes removed.Not in this PR (follow-ups)
Keyboard moderation (
j/k/a/s/d) + undo; author-insights drawer; realtime "N new" pill; rail pagination / surfacing pending replies in the rail; first-run intro dialog; pruning the now-dead table styles.Testing
npm run typecheck,npm run lint,npm run buildclean;npm run test:jsgreen (unrelated pre-existingdesktop-files-restore-syncfailure aside).edit-comments.php?p=opens filtered with the banner, the Related menu opens the native filtered window, the editor pencil opens the block editor as a window, and the connection spline draws to the editor. With the toggle off the classic screen is unchanged.