Skip to content

Redesign the native Comments window as a two-pane conversation view - #441

Merged
AllTerrainDeveloper merged 8 commits into
WordPress:trunkfrom
Alexismlg:feat/comments-conversation-redesign
Jul 29, 2026
Merged

Redesign the native Comments window as a two-pane conversation view#441
AllTerrainDeveloper merged 8 commits into
WordPress:trunkfrom
Alexismlg:feat/comments-conversation-redesign

Conversation

@Alexismlg

@Alexismlg Alexismlg commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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).

  • Left rail — the list of conversations (top-level comments per tab): avatar, author, snippet, post, status dot, reply count.
  • Right pane — the selected thread's full nested reply chain (every depth), each message carrying always-visible actions (Reply / Edit / Approve · Unapprove / Spam / Trash), plus a docked reply composer that repoints to whichever message you reply to.

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:

  1. Threading was one level deep. The conversation pane renders the entire nested tree, built client-side from a single thread fetch.
  2. Reply / edit were keyboard-only and undiscoverable. Every message now has visible action buttons — including nested replies.
  3. The surface read like wp-admin rather 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 classic edit-comments.php screen. 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)

  • Per-post scoping. 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= in onMatch and 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.
  • Related menu → native windows. The "Related" title-bar menu now consults 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 in desktop.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's onMatch). Flagging for review as it touches shared code.
  • Editor jump. A pencil in the conversation header opens the post's block editor as a Desktop Mode window (same link-interceptor path as the Drafts widget).
  • Connection spline. When scoped to a post, the window announces its content identity via 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.ts is a thin registry entry.
  • src/comments-window/rest.ts gains fetchThread() (approve + hold merged, since wp/v2/comments rejects a multi-value status) and an optional post filter on fetchComments.
  • src/comments-window/post-filter.ts (new) — the cross-bundle post-filter store.
  • src/desktop.ts — the Comments remap onMatch + the Related-menu remap consult.
  • includes/comments-window/window.php emits the two-pane shell; assets/css/comments-window.css adds the layout.
  • Reuses the existing REST helpers (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

  1. Base conversation redesign (two-pane, nested thread, per-message actions, composer).
  2. Post-filter store + fetchComments post param.
  3. Renderer: editor pencil, per-post filter UI, window-links identity.
  4. Framework: Related menu → native windows + Comments remap reads ?p=.
  5. Review fixes: fetchThread covers all four statuses (spam/trash threads render); consume-once post filter (no stale scope on a plain reopen); loadRail sequence guard; post-filter subscription dropped on window close; tabs toggle aria-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 build clean; npm run test:js green (unrelated pre-existing desktop-files-restore-sync failure aside).
  • Verified in wp-env with the native window enabled: nested threads at all depths, per-message actions without flicker (silent reloads), 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.

Alexismlg and others added 7 commits July 28, 2026 22:52
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

@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.

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:

  1. I'll use links button there
Image
  1. 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
Image
  1. Cliking on a conversation that is opened, reloads it.

  2. Improved loading speed by adding promises.all

  3. We are not using the wpd- web components.
    There is a huge list of wpd components that are reusable to keep UI consistent:

Image

OS Settings -> Components serves as an example library (Kind of story book self maintained)

Image

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
@Alexismlg

Copy link
Copy Markdown
Contributor Author

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:

  1. Links button — 👍
  2. Gutenberg-style external icon + clickable header — cleaner than my extra pencil button, happy to drop it.
  3. Reloading an open conversation on click — good catch.
  4. Promise.all speed-up
  5. Using the wpd-* components — 100% the right call; I'd actually flagged it as a follow-up in the description, so I'm really glad you're wiring it up properly with the kit. Thanks for the pointer to OS Settings → Components too.

Please go ahead with all of them — nothing feels worse or out of vision. Thanks again for the collaboration! 🚀

@AllTerrainDeveloper
AllTerrainDeveloper merged commit f4477b0 into WordPress:trunk Jul 29, 2026
5 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