eleven / ocho Public

web: reply in thread — the verb, pane-aware actions, and the ▤ Thread view row (#1464)

The docked thread pane (previous slice) grows its entry points and its
bubble-action routing, closing out the web side of the feature.

"Reply in thread" joins both message-action surfaces — the desktop picker
(hover React / press-and-hold / cmd-T) and the touch long-press sheet —
right after Reply, same glyph, same unconditional gating; the label tells
them apart. It resolves the tapped message's thread ROOT via replyTarget
(a reply's thread is its root's, never its own) and hands it to the
threads registry's open(), whose width check docks or lifts it — so on a
phone the verb opens today's overlay for free. From a bubble inside the
pane it's harmless: same root, openThread's already-open branch refocuses.

composerFor(anchor, ctx) is the new routing seam: a bubble action inside
`.chatapp-thread-panel--docked` lands in the pane's own composer (via the
registry's paneComposer), anywhere else in the main one. Mechanical swaps
at the hover strip's Reply/Edit, the picker's Reply/Edit, the sheet's
Reply/Edit (sheetActions gains the pressed `wrap` as a third param,
threaded from openMessageSheet), and the swipe-reply release. The cmd-R /
cmd-T hotkeys stay on composers.get — they act on the latest main-feed
message by definition. A pane-bubble Edit now shows the pane's own edit
banner (slice 2 made that render under fixedReply); its ✕ and Escape both
run the block's cancelEdit, code-traced for the pane instance.

Both thread openers are keyboard-operable (§10.6): the "N Replies" tally
becomes a real <button> like the quote card already was, with style.css
resetting the base pill chrome to the old tag look (transparent, no border,
zero padding, left-aligned so a stretched .them tally doesn't centre); the
pluralised label is untouched. Every open() call site now passes its
opener — the tally, the quote card, and the two verbs — so closeThread's
focus-return has somewhere to land. The verbs only record a focusable
anchor (tabIndex ≥ 0: the hover strip's button); press-and-hold and the
cmd-T hotkey anchor the picker on the bubble div, and passing that would
have swallowed closeThread's main-composer fallback.

▤ Views: chat.js exports activeThreadPane(threadId) (the registry entry
while a thread is open, else null); shell.js's viewsMenuEntries leads with
a "Thread" row while one is open — its open() refocuses the pane composer,
or focuses the overlay panel itself (tabIndex -1) at narrow widths — with
no remove handler (closing lives in the pane, and its lchat:views-changed
drops the row). Plain chats therefore show the ▤ button only while a
thread is open. The review's label nicety dropped in cleanly: the header
reads "Thread" while the pane is open and no command panel is up.

Adjacent fix, exercised directly by the new verb: the picker's buttons
dismissed with a bare pop.remove(), leaving its capture-phase keydown
listener alive until the next pointerdown — a "1" typed into the composer
an action had just focused toggled a reaction. Every picker button now
dismisses via teardown().

No event vectors touched; npm test green. Changelog entry lands here, for
the whole feature. iOS deliberately unchanged (no phone-width analogue);
Mac follows in its own slices.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 7132dc5bc8f4218165c8a84704800f627d69c80a parent 6ca847d
4 files changed, +150 −29
  • changelog/2026-08-28-thread-pane.md +7 −0
  • web/static/apps/chat.js +99 −22
  • web/static/shell.js +33 −6
  • web/static/style.css +11 −1
diff --git a/changelog/2026-08-28-thread-pane.md b/changelog/2026-08-28-thread-pane.md
new file mode 100644
index 0000000..7d97806
--- /dev/null
+++ b/changelog/2026-08-28-thread-pane.md
@@ -0,0 +1,7 @@
+🤖 Reply threads now open beside the chat on a desktop-sized screen instead
+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.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 2369580..a16b77c 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -277,6 +277,46 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item),
// composer without a second registry.
const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer() }
+// composerFor: the composer a bubble action (Reply, Edit, swipe-reply) lands
+// in — the docked thread pane's own when the acting bubble lives inside that
+// pane, else the thread's main composer. Decided on the DOM (is the anchor
+// inside `.chatapp-thread-panel--docked`?), not on state: the pane's rows come
+// from the same renderItem as the transcript's, so WHERE the bubble is is the
+// only thing that tells the two apart. The overlay has no pane composer
+// (paneComposer() is null), so it resolves to the main one — as before.
+function composerFor(anchor, ctx) {
+ const t = threads.get(ctx.threadId);
+ const pane = t && t.paneComposer && t.paneComposer();
+ if (pane && anchor && anchor.closest && anchor.closest(".chatapp-thread-panel--docked")) return pane;
+ return composers.get(ctx.threadId);
+}
+
+// openReplyThread: the "Reply in thread" verb — open the thread `m` belongs
+// to, anchored on its resolved ROOT (replyTarget: a reply's thread is its
+// root's, never the tapped reply's own), docked or lifted per openThread's
+// width check. From a bubble inside the pane itself this is harmless: same
+// root, so openThread's already-open branch just refocuses. The opener is
+// where focus returns on close, so only a node that can take focus is worth
+// recording: press-and-hold and the cmd/ctrl-T hotkey anchor the picker on
+// the bubble (a plain div), and handing closeThread an unfocusable node would
+// swallow its main-composer fallback.
+function openReplyThread(m, ctx, anchor) {
+ const t = threads.get(ctx.threadId);
+ if (!t) return;
+ const target = replyTarget(((ctx.getState() || {}).msgs) || [], m);
+ if (!target.id) return;
+ t.open(target.id, target, anchor && anchor.tabIndex >= 0 ? anchor : null);
+}
+
+// activeThreadPane: the open reply-thread controller for a mounted thread
+// (null when none is open) — how the shell's ▤ Views menu lists an open
+// thread as a view and puts focus back in it. openThread/closeThread dispatch
+// lchat:views-changed, so that menu repaints as the pane comes and goes.
+export function activeThreadPane(threadId) {
+ const t = threads.get(threadId);
+ return t && t.isOpen() ? t : null;
+}
+
// Threads whose next render should snap to the newest message even if the reader
// had scrolled up — set when you send from the composer (a reply included), since
// there's no optimistic echo: your message only lands on the server round-trip's
@@ -1150,11 +1190,12 @@ function renderItem(m, ctx) {
// A quote tap opens the whole thread, anchored on its resolved ROOT (the
// quoted ref may name a mid-chain parent on a legacy reply). replyTarget
// also hands us the root's name/snippet — the header fallback if the root
- // has scrolled out of the loaded window. Old scroll-jump kept as a belt
+ // has scrolled out of the loaded window. The card is the opener — focus
+ // returns to it when the view closes. Old scroll-jump kept as a belt
// for a mount without thread controls.
const t = threads.get(ctx.threadId);
const ref = replyTarget(((ctx.getState() || {}).msgs) || [], m);
- if (t && ref.id) t.open(ref.id, ref);
+ if (t && ref.id) t.open(ref.id, ref, q);
else scrollToMessage(wrap, m.reply.id);
};
content.appendChild(q);
@@ -1288,14 +1329,14 @@ function attachActions(wrap, content, m, ctx) {
actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget)));
actions.appendChild(
hoverBtn("reply-hover", "Reply", icon("reply"), () => {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(wrap, ctx);
if (ctl) ctl.startReply(m);
}),
);
const remaining = m.ts + EDIT_WINDOW_MS - Date.now();
if (mine && m.text.trim() && remaining > 0) {
const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(wrap, ctx);
if (ctl) ctl.startEdit(m);
});
actions.appendChild(editBtn);
@@ -1360,15 +1401,18 @@ function buildReactionsRow(m, ctx) {
// The "N Replies" tally below a bubble, tappable to open the thread view. The
// tally only ever renders under a loaded root (it IS this message), so no
// paging is needed on this path. Old jump-to-first-reply kept as a belt for a
-// mount without thread controls.
+// mount without thread controls. A real <button> (style.css resets the pill
+// chrome), like the quote card: the thread is keyboard-reachable, and it's
+// the opener the view hands focus back to on close.
function buildReplyCount(m, wrap, ctx) {
- const rc = document.createElement("div");
+ const rc = document.createElement("button");
+ rc.type = "button";
rc.className = "reply-count";
rc.textContent = m.replyCount === 1 ? "1 Reply" : m.replyCount + " Replies";
rc.onclick = () => {
const t = threads.get(ctx.threadId);
if (t) {
- t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) });
+ t.open(m.sid, { id: m.sid, name: m.name, snippet: replySnippet(m.raw) }, rc);
return;
}
const feed = wrap.closest(".chatapp-feed");
@@ -1530,10 +1574,14 @@ function openMessagePicker(m, ctx, anchor) {
b.textContent = emoji;
b.onclick = () => {
toggleReaction(m, emoji, ctx);
- pop.remove();
+ teardown();
};
pop.appendChild(b);
}
+ // Every button dismisses via teardown (below), never a bare pop.remove():
+ // the picker's document listeners must go with it, or the digit/Escape
+ // handler outlives the menu and a "1" typed into the composer the action
+ // just focused toggles a reaction instead.
const action = (svg, label, run) => {
const b = document.createElement("button");
b.className = "picker-action";
@@ -1542,14 +1590,20 @@ function openMessagePicker(m, ctx, anchor) {
b.title = label;
b.onclick = () => {
run();
- pop.remove();
+ teardown();
};
pop.appendChild(b);
};
action(icon("reply"), "Reply", () => {
- const ctl = composers.get(ctx.threadId);
+ 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
+ // bubble itself (unfocusable — openReplyThread drops it).
+ action(icon("reply"), "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()) {
@@ -1572,7 +1626,7 @@ function openMessagePicker(m, ctx, anchor) {
const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName);
if (mine && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS) {
action(icon("edit"), "Edit", () => {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(anchor, ctx);
if (ctl) ctl.startEdit(m);
});
}
@@ -1603,7 +1657,7 @@ function openMessagePicker(m, ctx, anchor) {
b.title = act.label;
b.onclick = () => {
act.run();
- pop.remove();
+ teardown();
};
pop.appendChild(b);
}
@@ -1640,19 +1694,24 @@ function openMessagePicker(m, ctx, anchor) {
}
// The actions in the touch long-press sheet, in the original feed's order: Reply,
-// Copy, Copy link, then app-contributed actions (Save link…), then your-own Edit (in-window)
-// and Delete. Each is { label, icon | glyph, danger?, run } — mirrors messageActions
-// in the original chat.js.
-function sheetActions(m, ctx) {
+// Reply in thread, Copy, Copy link, then app-contributed actions (Save link…),
+// then your-own Edit (in-window) and Delete. Each is { label, icon | glyph,
+// danger?, run } — mirrors messageActions in the original chat.js. `wrap` is
+// the pressed bubble: Reply/Edit route to the composer it shares a pane with
+// (the docked thread pane's own when it lives there), and it's the thread
+// verb's opener.
+function sheetActions(m, ctx, wrap) {
const acts = [];
acts.push({
label: "Reply",
icon: icon("reply"),
run: () => {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(wrap, ctx);
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) });
// Native selection is suppressed on bubbles (long-press opens this sheet), so
// Copy must live here.
if (m.text.trim()) {
@@ -1684,7 +1743,7 @@ function sheetActions(m, ctx) {
label: "Edit",
icon: icon("edit"),
run: () => {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(wrap, ctx);
if (ctl) ctl.startEdit(m);
},
});
@@ -1759,7 +1818,7 @@ function openMessageSheet(m, ctx, wrap) {
const menu = document.createElement("div");
menu.className = "msg-sheet-menu";
- for (const a of sheetActions(m, ctx)) {
+ for (const a of sheetActions(m, ctx, wrap)) {
const item = document.createElement("button");
item.className = "msg-sheet-item" + (a.danger ? " danger" : "");
const label = document.createElement("span");
@@ -2035,7 +2094,7 @@ function attachSwipeReply(wrap, m, ctx) {
cleanup();
mode = "";
if (trigger) {
- const ctl = composers.get(ctx.threadId);
+ const ctl = composerFor(wrap, ctx);
if (ctl) ctl.startReply(m);
}
};
@@ -3525,7 +3584,12 @@ function buildSkeleton(el, ctx) {
// on `spec`: hidePanel's eased teardown lands here as showPanel(null),
// possibly AFTER openThread already re-set threadSid (the 200ms roll-up),
// and that must never close the thread it was cleared for.
- if (spec && threadSid && threadDocked) closeThread();
+ if (spec && threadSid && threadDocked) {
+ // Never silently drop what was typed: a pane edit in flight keeps its
+ // pane, and this panel takes the top band instead of the dock.
+ if (paneBlock && paneBlock.api.editing()) spec = { ...spec, position: "top" };
+ else closeThread();
+ }
const prev = panelSpec;
panelSpec = spec && typeof spec.render === "function" ? spec : null;
clearPanelEase(); // a mid-animation replace starts clean
@@ -4083,7 +4147,20 @@ function buildSkeleton(el, ctx) {
// we run — so one Escape peels one layer, never two. Docked, a staged
// reply the user armed on the main composer is the newer layer: cancel it
// (the branch below) before an Escape reaches the pane.
- if (e.key === "Escape" && threadSid && !api.editing() && !(threadDocked && api.stagedReply()) && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ // A pane edit in flight is the innermost layer of all: focus in neither
+ // field, Escape cancels THAT — never the pane around it, which would
+ // drop the typed edit (the pane's own field handles it when focused).
+ const paneEditing = !!(threadDocked && paneBlock && paneBlock.api.editing());
+ if (e.key === "Escape" && paneEditing && !e.defaultPrevented && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ e.preventDefault();
+ paneBlock.api.cancelEdit();
+ return;
+ }
+ // Keyboard Escape is never destructive (the Mac rule too): a docked pane
+ // holding a typed draft or staged files stays put — the ✕ is the
+ // deliberate close-with-content gesture.
+ const paneHasContent = !!(threadDocked && paneBlock && (!paneBlock.composer.isEmpty() || paneBlock.api.staged()));
+ if (e.key === "Escape" && threadSid && !paneHasContent && !api.editing() && !(threadDocked && api.stagedReply()) && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
e.preventDefault();
closeThread();
return;
diff --git a/web/static/shell.js b/web/static/shell.js
index 39617eb..b2e071e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -146,7 +146,7 @@ import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admi
import "/static/apps/webhooks.js"; // incoming-webhooks management (admin-only; /webhooks — docs/webhooks.md)
import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md)
import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md)
-import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf } from "/static/apps/chat.js";
+import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf, activeThreadPane } from "/static/apps/chat.js";
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js";
@@ -3741,11 +3741,35 @@ function threadHasAIPeer(id) {
});
}
-// viewsMenuEntries: what the ▤ Views dropdown offers for a thread — Memory
-// (the persistent view of an AI DM) first, then every live bot view, newest
-// first (the same order /views shows). Empty = the button hides.
+// viewsMenuEntries: what the ▤ Views dropdown offers for a thread — an open
+// reply thread first (it's the view you're in), then Memory (the persistent
+// view of an AI DM), then every live bot view, newest first (the same order
+// /views shows). Empty = the button hides.
function viewsMenuEntries(id) {
const entries = [];
+ // The reply thread is chat.js's (apps/chat.js `threads`), not a folded
+ // view: its row is there exactly while one is open — open/close dispatch
+ // lchat:views-changed — and carries no remove, since closing happens in
+ // the pane itself (✕ / Escape). Opening it puts focus back in the thread:
+ // the docked pane's own composer at desktop widths, else the lifted
+ // overlay's panel (the narrow presentation; its reply box is the main
+ // composer, already staged to the thread).
+ if (activeThreadPane(id)) {
+ entries.push({
+ svg: ICON_REPLY,
+ label: "Thread",
+ open: () => {
+ const t = activeThreadPane(id); // re-resolved: the menu may outlive a close
+ if (!t) return;
+ const pane = t.paneComposer && t.paneComposer();
+ if (pane) return pane.focus();
+ const panel = document.querySelector("#app-pane .chatapp-thread-panel");
+ if (!panel) return;
+ panel.tabIndex = -1; // programmatic focus only — never a Tab stop of its own
+ panel.focus();
+ },
+ });
+ }
if (threadHasAIPeer(id)) entries.push({ svg: ICON_BRAIN, label: "Memory", open: () => openMemoryPanel(id) });
const st = appStateFor(id, "views");
const views = st && Array.isArray(st.views) ? st.views : [];
@@ -3775,9 +3799,12 @@ function paintViewsMenu(id) {
if (!wrap) return;
wrap.classList.toggle("hidden", !id || (viewsMenuEntries(id).length === 0 && !openPanelTitle[id]));
// At rest the button offers "Open"; while a view is up it names it — the
- // header tells you where you are, not just what exists.
+ // header tells you where you are, not just what exists. An open reply
+ // thread names itself the same way; a command panel wins when one is up
+ // (docked, the two can't share the dock, so that's only ever the narrow
+ // overlay + a bottom band).
const label = document.getElementById("views-menu-label");
- if (label) label.textContent = (id && openPanelTitle[id]) || "Open";
+ if (label) label.textContent = (id && openPanelTitle[id]) || (id && activeThreadPane(id) ? "Thread" : "Open");
}
function paintThreadHeader(id) {
diff --git a/web/static/style.css b/web/static/style.css
index eb93655..ac7ee69 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -656,6 +656,7 @@ select:disabled { opacity: 0.5; cursor: default; }
@media (hover: hover) {
.msg:hover .msg-actions { opacity: 1; pointer-events: auto; }
}
+.msg:focus-within .msg-actions { opacity: 1; pointer-events: auto; } /* keyboard: a focused strip is a visible strip */
/* message content (text bubble + any attachments) */
.content {
@@ -1289,15 +1290,24 @@ select:disabled { opacity: 0.5; cursor: default; }
white-space: nowrap;
}
.msg.me .reply-quote-text { margin-left: auto; }
-/* "N Replies" tag under a message that's been replied to (opens the thread view) */
+/* "N Replies" tag under a message that's been replied to (opens the thread view).
+ A <button> like the quote card — keyboard-reachable, and the opener the view
+ hands focus back to — so the base pill chrome is reset to the old tag look. */
.reply-count {
font-size: 11px;
font-weight: 600;
color: var(--link);
margin: 3px 6px 0;
cursor: pointer;
+ background: transparent;
+ border: none;
+ border-radius: 4px; /* a tidy focus ring, not the pill's */
+ padding: 0;
+ text-align: left; /* stretched under a .them bubble; me shrinks to content */
+ line-height: inherit;
}
.reply-count[hidden] { display: none; }
+.reply-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* flash a message when you jump to it from a quote */
.msg.flash { animation: flash-msg 1.2s ease; border-radius: var(--radius); }
@keyframes flash-msg {