web: "Reply in thread" gets its own icon in the desktop hover strip (#1487)
Starting a thread was undiscoverable on the web. "Reply in thread" existed only inside the message picker menu — which on desktop opens by clicking the *React* smiley (or press-and-hold, or ⌘T). Nobody hunting for threads has a reason to click a smiley, so the feature read as one we hadn't built, while plain Reply sat right there in the hover strip as its own button. So give it one too, immediately after Reply, calling the same openReplyThread verb the menu calls and passing itself as the opener so focus returns to the icon when the thread closes. The picker and the touch sheet keep their rows — this is an additional, more visible route, not a move. A distinct glyph is what makes that work: ICON_THREAD (Lucide messages-square) — two speech bubbles, the second budding out from behind the first. Reply already owns the arrow, and two arrows a few pixels apart read as one control drawn twice. The picker and sheet adopt it as well, so the two verbs look as different as they behave everywhere. hoverBtn now mirrors its label into title=, the way the picker's rows already did. Inside a thread the button is hidden (both presentations, overlay and docked) — the same self-pointing-row rule the quote card and reply tally follow, since the verb would resolve to the thread you're already standing in. display:none keeps it out of the tab order too. Fit: the strip's buttons lose 1px of side padding (28x26 still clears the 24x24 target-size floor), and the gutter it lives in gets a pixel floor on hover devices instead of merely "whatever 78% leaves over" — with the thread pane docked the feed loses ~340px, and a max-width bubble used to push the strip past the feed's edge where overflow-x silently ate the timestamp. Four icons + the time now fit at every desktop width down to the 60% backstop. Verified headless against the real stylesheet: nothing wraps, crowds or clips at 900px and 600px feeds in light and dark, and the button computes to display:none in the overlay panel, the docked column, and both pinned roots. Web-only: native's message menus carry text labels, so their twin reply arrows are tolerable — docs/icons.md notes the SF Symbol owed on the next native touch. The thread-pane changelog entry is still unannounced (stable and beta are both on c41be8e; only edge carries it), so this folds a clause into that entry rather than adding a second one. Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+85
−27
changelog/2026-08-28-thread-pane.md+13 −10docs/icons.md+8 −0web/static/apps/chat.js+29 −9web/static/icons.js+10 −0web/static/shell.js+2 −2web/static/style.css+23 −6
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md| index e642dd3..0cb785d 100644 |
| --- a/changelog/2026-08-28-thread-pane.md |
| +++ b/changelog/2026-08-28-thread-pane.md |
| @@ -2,13 +2,16 @@ |
| of over it. Tap a message's "N Replies" tally or its quote card and the thread |
| docks as a column on the right, with its own reply box at the bottom — so you |
| can follow the main conversation and the thread at the same time. The message |
| -menu has a new "Reply in thread" next to "Reply", and while a thread is open |
| -you'll find it under the ▤ Views button in the header. On a phone the thread |
| -still lifts over the chat the way it always has. Replies you make in a thread |
| -now stay in the thread: the chat itself shows just the "N Replies" tally under |
| -the original message (tap it to read them), so a busy thread no longer crowds |
| -the conversation. A plain Reply still lands in the chat as before, and a |
| -search hit or link to a thread reply opens the thread for you. And where a |
| -reply in the chat quotes a message whose thread has grown, its quote card now |
| -shows that thread's reply count — the same number the original message shows — |
| -so you can see there's more to read and tap straight through to it. |
| +menu has a new "Reply in thread" next to "Reply" — and on a computer it's a |
| +button on the message itself: hover any message and it sits beside the reply |
| +arrow, drawn as two speech bubbles so the two are easy to tell apart. While a |
| +thread is open you'll find it under the ▤ Views button in the header. On a |
| +phone the thread still lifts over the chat the way it always has. Replies you |
| +make in a thread now stay in the thread: the chat itself shows just the |
| +"N Replies" tally under the original message (tap it to read them), so a busy |
| +thread no longer crowds the conversation. A plain Reply still lands in the |
| +chat as before, and a search hit or link to a thread reply opens the thread |
| +for you. And where a reply in the chat quotes a message whose thread has |
| +grown, its quote card now shows that thread's reply count — the same number |
| +the original message shows — so you can see there's more to read and tap |
| +straight through to it. |
diff --git a/docs/icons.md b/docs/icons.md| index 8779617..cbfb590 100644 |
| --- a/docs/icons.md |
| +++ b/docs/icons.md |
| @@ -41,6 +41,7 @@ Three kinds of mark, three sources, one rule about emoji. |
| | Meaning | icons.js (Lucide slug) | SF Symbol (native) | |
| |---|---|---| |
| | Reply | `ICON_REPLY` (hand-drawn, pre-Lucide) | `arrowshape.turn.up.left` | |
| +| Reply in thread | `ICON_THREAD` (messages-square) | `bubble.left.and.bubble.right` | |
| | Close / dismiss | `ICON_X` | `xmark` | |
| | Edit | `ICON_EDIT` | `pencil` | |
| | React | `ICON_REACT` | `face.smiling` | |
| @@ -81,3 +82,10 @@ iOS thread list/search/share rows, the Mac sidebar, rail, feed header, |
| search, ⌘K palette, and both New-chat pickers. The old "▦" placeholder is |
| gone — unmapped apps get `square.grid.2x2`. Device verify + the release |
| changelog ride the next native release mint. |
| + |
| +One row of the table is ahead of native: **Reply in thread**. The web strip |
| +and menus now draw `ICON_THREAD`, but the native message menus still label a |
| +second `arrowshape.turn.up.left` (`StockChatFeed.swift`, `MessageBubble.swift`) |
| +— tolerable there because those rows carry text, whereas the web hover strip is |
| +icon-only and two arrows read as one control drawn twice. Swap them to |
| +`bubble.left.and.bubble.right` on the next native touch. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index ef1890a..e970dac 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -1394,8 +1394,9 @@ function renderItem(m, ctx) { |
| return item; |
| } |
| -// The interactive message actions — desktop's hover strip (react / reply / edit / |
| -// copy-link) plus its press-and-hold, or touch's swipe-reply + long-press picker. |
| +// The interactive message actions — desktop's hover strip (react / reply / |
| +// reply-in-thread / edit / copy-link) plus its press-and-hold, or touch's |
| +// swipe-reply + long-press picker. |
| // Split out of renderItem so patchBubble can attach them to a bubble that first |
| // rendered while pending (actions withheld) the moment its echo confirms it — with |
| // the now-real server id, and without rebuilding the bubble (no photo flash). |
| @@ -1417,6 +1418,19 @@ function attachActions(wrap, content, m, ctx) { |
| if (ctl) ctl.startReply(m); |
| }), |
| ); |
| + // Reply in thread, right beside Reply — the two are siblings, so they belong |
| + // side by side. It was menu-only (the picker + the touch sheet, both of which |
| + // keep it), and on desktop that menu opens from the *React* smiley: nobody |
| + // hunting for threads had a reason to click a smiley, so threads read as a |
| + // feature we hadn't built. The distinct glyph is what makes two neighbouring |
| + // reply verbs legible — see ICON_THREAD. The button is its own opener, so |
| + // closing the thread puts focus back on the icon that opened it. Inside the |
| + // thread view this button is hidden (style.css), the same self-pointing-row |
| + // rule as the quote card and the reply tally: the verb would resolve to the |
| + // thread you're already standing in. |
| + actions.appendChild( |
| + hoverBtn("thread-hover", "Reply in thread", icon("thread"), (e) => openReplyThread(m, ctx, e.currentTarget)), |
| + ); |
| const remaining = m.ts + EDIT_WINDOW_MS - Date.now(); |
| if (mine && m.text.trim() && remaining > 0) { |
| const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => { |
| @@ -1602,6 +1616,10 @@ function hoverBtn(className, label, svg, onclick) { |
| b.className = className; |
| b.innerHTML = svg; // static in-code SVG from the host (safe) |
| b.setAttribute("aria-label", label); |
| + // Same label as a native tooltip (the picker's actions already do this): an |
| + // icon-only strip is only self-explanatory once you know the glyphs, and |
| + // "Reply" vs "Reply in thread" is exactly the distinction hovering settles. |
| + b.title = label; |
| b.onclick = (e) => { |
| e.stopPropagation(); |
| onclick(e); |
| @@ -1731,12 +1749,13 @@ function openMessagePicker(m, ctx, anchor) { |
| const ctl = composerFor(anchor, ctx); |
| if (ctl) ctl.startReply(m); |
| }); |
| - // Reply in thread — the same glyph as Reply, the label tells them apart: |
| - // opens the message's thread (docked beside the chat at desktop widths, |
| - // lifted over it on narrow ones) rather than staging a reply below. The |
| - // anchor is the opener: the hover strip's button on that path, else the |
| + // Reply in thread — opens the message's thread (docked beside the chat at |
| + // desktop widths, lifted over it on narrow ones) rather than staging a reply |
| + // below. Its own glyph, not a second reply arrow: this menu's rows are |
| + // icon-only, so twin arrows left the two verbs told apart by tooltip alone. |
| + // The anchor is the opener: the hover strip's button on that path, else the |
| // bubble itself (unfocusable — openReplyThread drops it). |
| - action(icon("reply"), "Reply in thread", () => openReplyThread(m, ctx, anchor)); |
| + action(icon("thread"), "Reply in thread", () => openReplyThread(m, ctx, anchor)); |
| // Copy — the original touch sheet's action; native selection is suppressed on |
| // bubbles (long-press opens this menu), so Copy must live here. |
| if (m.text.trim()) { |
| @@ -1843,8 +1862,9 @@ function sheetActions(m, ctx, wrap) { |
| if (ctl) ctl.startReply(m); |
| }, |
| }); |
| - // Same glyph as Reply; the label tells them apart (see the desktop picker). |
| - acts.push({ label: "Reply in thread", icon: icon("reply"), run: () => openReplyThread(m, ctx, wrap) }); |
| + // Its own glyph (see the desktop picker) — the sheet's rows are labelled, so |
| + // this is about the two verbs looking as different as they behave. |
| + acts.push({ label: "Reply in thread", icon: icon("thread"), run: () => openReplyThread(m, ctx, wrap) }); |
| // Native selection is suppressed on bubbles (long-press opens this sheet), so |
| // Copy must live here. |
| if (m.text.trim()) { |
diff --git a/web/static/icons.js b/web/static/icons.js| index 38376d9..1e697f8 100644 |
| --- a/web/static/icons.js |
| +++ b/web/static/icons.js |
| @@ -72,6 +72,16 @@ export const ICON_COPY = |
| 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| '<rect x="9" y="9" width="12" height="12" rx="2"></rect>' + |
| '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; |
| +// Two speech bubbles, the second budding out from behind the first — "Reply in |
| +// thread". Deliberately NOT a second reply arrow: it sits immediately beside |
| +// Reply in the desktop hover strip, and two arrows a few pixels apart read as |
| +// one control drawn twice. Bubble-begets-bubble says "a conversation branching |
| +// off this message" before the tooltip has to. (Lucide: messages-square) |
| +export const ICON_THREAD = |
| + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| + 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + |
| + '<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>' + |
| + '<path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1"></path></svg>'; |
| // Chain link, for the "Copy link" message-permalink action (issue #893). |
| export const ICON_LINK = |
| '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' + |
diff --git a/web/static/shell.js b/web/static/shell.js| index b2e071e..05aa68d 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -858,7 +858,7 @@ const EDIT_WINDOW_MS = 15 * 60 * 1000; // mirrors editWindow (hub.go); server en |
| const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; |
| // The UI icon set (web/static/icons.js — Lucide idiom, docs/icons.md). |
| -import { ICON_REPLY, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| +import { ICON_REPLY, ICON_THREAD, ICON_X, ICON_EDIT, ICON_REACT, ICON_TRASH, ICON_PLAY, ICON_PIN, ICON_MUTE, ICON_REPORT, ICON_COPY, ICON_LINK, ICON_CHEVRON_DOWN, ICON_CHEVRON_LEFT, ICON_MORE_VERTICAL, ICON_BRAIN, ICON_WRENCH } from "/static/icons.js"; |
| const app = document.getElementById("app"); |
| @@ -5485,7 +5485,7 @@ setChatAppHost({ |
| } |
| return null; |
| }, |
| - icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN }, |
| + icons: { reply: ICON_REPLY, thread: ICON_THREAD, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, link: ICON_LINK, mute: ICON_MUTE, report: ICON_REPORT, chevronDown: ICON_CHEVRON_DOWN }, |
| // Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted, |
| // labelled, and run by the host — the app just mounts the returned row. |
| actionChips: (actions) => { |
diff --git a/web/static/style.css b/web/static/style.css| index 06a2820..fb6acb9 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -636,10 +636,22 @@ select:disabled { opacity: 0.5; cursor: default; } |
| } |
| .msg.them .msg-actions { left: 100%; padding-left: 4px; } /* just right of the bubble */ |
| .msg.me .msg-actions { right: 100%; padding-right: 4px; flex-direction: row-reverse; } /* just left */ |
| +/* Padding shaved from 6px to 5px when Reply-in-thread joined the strip — four |
| + icons plus the time now, on a bubble's shoulder. Not shaved further: an 18px |
| + glyph in 10px of padding keeps every button 28x26, clear of the 24x24 |
| + target-size floor (WCAG 2.2 AA 2.5.8), and the 2px gap keeps neighbours |
| + distinct. The strip is flex-nowrap and shrink-to-fit, so it can never wrap |
| + or squash — where room runs out it simply runs past the feed's edge and |
| + overflow-x clips it (the gutter floor below keeps that off the main feed; |
| + the thread pane's own narrow column is where it still bites). The time is |
| + last at BOTH alignments (.me reverses the row), so the graceful casualty is |
| + a timestamp, never an action — which is why the gutter is left alone: a |
| + fourth icon is not worth reshaping every bubble in every chat to widen it, |
| + and own-messages already clipped this way before the icon existed. */ |
| .msg-actions button { |
| background: transparent; |
| color: var(--muted); |
| - padding: 4px 6px; |
| + padding: 4px 5px; |
| border-radius: 50%; |
| display: inline-flex; |
| align-items: center; |
| @@ -651,7 +663,7 @@ select:disabled { opacity: 0.5; cursor: default; } |
| font-size: 11px; |
| color: var(--muted); |
| white-space: nowrap; |
| - margin: 0 4px; |
| + margin: 0 3px; |
| } |
| @media (hover: hover) { |
| .msg:hover .msg-actions { opacity: 1; pointer-events: auto; } |
| @@ -1458,10 +1470,14 @@ select:disabled { opacity: 0.5; cursor: default; } |
| flex-direction: column; |
| gap: 4px; |
| } |
| -/* inside the view, every row is already the thread — a per-row quote card or |
| - tally would only point back at where you're standing */ |
| +/* inside the view, every row is already the thread — a per-row quote card, a |
| + tally, or a "Reply in thread" icon would only point back at where you're |
| + standing (the verb resolves to this same root, so the button is a no-op that |
| + still costs a slot in a four-icon strip). display:none also drops it from |
| + the tab order, so keyboard users don't hit a dead control either. */ |
| .chatapp-thread .reply-quote, |
| -.chatapp-thread .reply-count { display: none; } |
| +.chatapp-thread .reply-count, |
| +.chatapp-thread .thread-hover { display: none; } |
| /* ---- docked presentation (apps/chat.js): at desktop widths the thread parks |
| as a flush right column beside the transcript instead of lifting over it — |
| the command panel's side-dock technique (apps.js .cmd-panel--side) under |
| @@ -1475,7 +1491,8 @@ select:disabled { opacity: 0.5; cursor: default; } |
| band, the same accepted trade-off as .cmd-panel--side (width is decided at |
| open time; no live resize listeners anywhere). */ |
| .chatapp-thread-panel--docked .reply-quote, |
| -.chatapp-thread-panel--docked .reply-count { display: none; } /* same self-pointing-row rule as the overlay (no .chatapp-thread wrapper here) */ |
| +.chatapp-thread-panel--docked .reply-count, |
| +.chatapp-thread-panel--docked .thread-hover { display: none; } /* same self-pointing-row rule as the overlay (no .chatapp-thread wrapper here) */ |
| @media (min-width: 721px) { |
| .chatapp-root.has-thread-pane { padding-right: min(340px, 40%); } |
| .chatapp-thread-panel--docked { |