eleven / ocho Public

web: docked thread pane — reply threads dock beside the chat at desktop widths (#1463)

Opening a reply thread now forks on width, decided per open at open time
(point-in-time matchMedia, the codebase's width idiom — no resize listeners):
below 721px the overlay is byte-for-byte today's; at 721px and up the thread
parks as a flush right column beside the transcript, Slack-style, using the
command panel's side-dock technique (root padding gutter + absolute column)
under thread-owned class names (has-thread-pane, .chatapp-thread-panel--docked).
Docked it's a non-modal complementary landmark; the overlay keeps role=dialog
and gains aria-modal. Focus moves into the pane on open (rAF, skipped on
coarse pointers like the overlay's staging focus) and returns to the opener
element on close — openThread grows the third param that PR 3's verbs will
pass. Open and close both dispatch lchat:views-changed for PR 3's ▤ row.

The docked pane carries its OWN composer — a second buildComposerBlock
instance in a .chatapp-thread-composer wrapper with fixedReply deriving the
root target fresh per send (no staging, no banner) and commands off ("/"
sends verbatim; the command typeahead now stands down with cfg.commands too,
closing the gap slice 1's review flagged). The block's whole 4-node fragment
mounts inside the wrapper: its float is required (it carries the staging
strip and the emoji/@ typeahead — unmounted, the typeahead would arm
invisibly and swallow Enter) and is not a command-panel anchor (showPanel
only ever targets the main block's captured float); nesting keeps the
`.chatapp-root >` embed/readonly sweeps blind to it, with an explicit
descendant rule hiding it in those modes. setConnected reaches the pane's
send button through the threads registry's new paneComposer() accessor
(review obligation a), and render()'s beat ticker resolves the pane lazily
each tick so its typing indicator sustains and stops like the main one's.

Mutual exclusion — one right dock: openThread calls hidePanel(), and
showPanel closes an open thread first, gated on a real spec so hidePanel's
eased teardown (which lands as showPanel(null) up to ~200ms later, possibly
after a thread just opened) can never close the thread it was cleared for.

Docked, the main composer stays entirely its own: post-send re-staging,
edit-cancel re-staging, the reply-banner-✕-closes-the-thread rule, and
close-time staged-reply cleanup are all overlay-only now (!threadDocked).
The Escape ladder holds one-Escape-one-layer across both presentations: a
consumed Escape in either block now marks the event handled (with two live
composers the document dispatcher can't tell consumption from focus alone),
and docked, a staged main-composer reply cancels before an Escape reaches
the pane. refreshThread stays presentation-agnostic — empty-thread and
deleted-root auto-close work docked unchanged.

CSS is additive (overlay rules untouched): the docked block reuses the real
tokens (--panel/--border, light-dark() so both themes hold), overrides the
frosted-glass @supports rule by source order (nothing shows through a flush
column), replicates the overlay's self-pointing reply-quote/tally hiding and
coarse-pointer selection suppression for the pane's bubbles. No changelog
entry yet — that lands with slice 3 when the feature grows its entry points.


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 485958b50e6d03271c277af6350c0918aa643bfd parent 5f9ce9f
2 files changed, +259 −47
  • web/static/apps/chat.js +228 −47
  • web/static/style.css +31 −0
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index fdfa5b2..2369580 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -272,7 +272,10 @@ const composers = new Map(); // threadId → { refreshSend(), startReply(item),
// The reply-thread view controller for each mounted thread, so bubble taps (a
// quote card, a "N Replies" tally) can lift that thread over the pane they live
// in. Built alongside the composer in buildSkeleton, overwritten per mount.
-const threads = new Map(); // threadId → { open(rootSid, ref), close(), isOpen() }
+// paneComposer() hands out the docked pane's composer api while one is mounted
+// (null otherwise) — how setConnected and bubble actions reach the second
+// composer without a second registry.
+const threads = new Map(); // threadId → { open(rootSid, ref, opener), close(), isOpen(), paneComposer() }
// 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
@@ -289,6 +292,13 @@ let connected = true;
export function setConnected(v) {
connected = v;
for (const c of composers.values()) c.refreshSend();
+ // The docked thread pane's composer isn't in `composers` (one entry per
+ // thread, the main block's) — reach it through the threads registry so its
+ // send button greys with the socket too.
+ for (const t of threads.values()) {
+ const pane = t.paneComposer && t.paneComposer();
+ if (pane) pane.refreshSend();
+ }
}
// Successful unfurl results cached by URL so a bubble re-render doesn't
@@ -633,6 +643,11 @@ export const app = defineApp({
.chatapp-root.readonly .typeahead,
.chatapp-root.readonly > .composer-reply,
.chatapp-root.readonly > .composer-edit { display: none; }
+/* The docked thread pane's composer is nested (not a root child), so the two
+ child-combinator sweeps above miss it — same rule, same reasons: embedded,
+ the shell draws the composer; read-only, nobody replies. */
+.chatapp-root.embed .chatapp-thread-composer,
+.chatapp-root.readonly .chatapp-thread-composer { display: none; }
/* Embedded: keep the attachment preview visible (a staged photo shows just above
the native composer) and give the feed the native safe-area insets — mirrors
the original feed's .native-embed rules. */
@@ -877,6 +892,15 @@ export const app = defineApp({
// composer, so re-registering the timer each draw is harmless).
const composer = composers.get(ctx.threadId);
if (composer && composer.tickTyping) ctx.interval(composer.tickTyping, 1000);
+ // The docked thread pane's composer beats typing too. Resolved lazily on
+ // each tick, not captured: the pane opens and closes between renders (no
+ // re-render marks either), so a captured handle would go stale — and with
+ // no pane this is a 1s no-op, the same cost as the ticker above.
+ ctx.interval(() => {
+ const t = threads.get(ctx.threadId);
+ const pane = t && t.paneComposer && t.paneComposer();
+ if (pane && pane.tickTyping) pane.tickTyping();
+ }, 1000);
},
});
@@ -950,17 +974,22 @@ export function repaintMentionsOf(names) {
// slot — inline, never floating over the feed.
function paintClosedState(root, ctx) {
const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId));
- const compose = root.querySelector(".composer");
- const float = root.querySelector(".composer-float");
+ // Direct children only: a docked thread pane nests its own ".composer",
+ // and this paint must never grab that one by first match.
+ const compose = root.querySelector(":scope > .composer");
+ const float = root.querySelector(":scope > .composer-float");
+ const paneCompose = root.querySelector(".chatapp-thread-composer");
let bar = root.querySelector(":scope > .dm-closed-bar");
if (!closed) {
if (bar) bar.remove();
if (compose) compose.classList.remove("hidden");
if (float) float.classList.remove("hidden");
+ if (paneCompose) paneCompose.classList.remove("hidden");
return;
}
if (compose) compose.classList.add("hidden");
if (float) float.classList.add("hidden");
+ if (paneCompose) paneCompose.classList.add("hidden");
if (bar) return; // already painted; keep any inline error it shows
bar = document.createElement("div");
bar.className = "dm-closed-bar";
@@ -2604,7 +2633,9 @@ function buildComposerBlock(ctx, host, cfg) {
if (primary) applyComposerHint(ctx.threadId);
const refreshBanners = () => {
- if (cfg.fixedReply) return; // a root-pinned block derives its reply per send — no banner
+ // A root-pinned block derives its reply per send — no reply banner. The
+ // EDIT banner still renders: entering edit mode without saying so would
+ // let Enter silently overwrite an earlier message.
replyBanner.textContent = "";
replyBanner.classList.add("hidden");
editBanner.textContent = "";
@@ -2618,7 +2649,7 @@ function buildComposerBlock(ctx, host, cfg) {
x.onclick = cancelEdit;
editBanner.appendChild(x);
editBanner.classList.remove("hidden");
- } else if (replyTo) {
+ } else if (replyTo && !cfg.fixedReply) {
const bar = document.createElement("div");
bar.className = "reply-bar";
const ic = document.createElement("span");
@@ -2950,6 +2981,10 @@ function buildComposerBlock(ctx, host, cfg) {
const updateTypeahead = () => {
const tok = composerToken(composer.getText(), composer.getCaret());
if (!tok || !host || !host.typeaheadItems) return closeTypeahead();
+ // No command routing (cfg.commands off) means no command typeahead either:
+ // a leading "/" in this block is ordinary text that sends verbatim (the
+ // thread pane's composer), so offering completions would be a lie.
+ if (tok.kind === "command" && !cfg.commands) return closeTypeahead();
const items = host.typeaheadItems(tok);
if (!items.length) return closeTypeahead();
typeahead = { kind: tok.kind, start: tok.start, items, index: 0 };
@@ -3030,15 +3065,21 @@ function buildComposerBlock(ctx, host, cfg) {
}
if (e.key === "Escape" && sendQueued) {
setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
+ e.preventDefault(); // handled — the document dispatcher must not also peel a layer
return;
}
// Pane-owned layers (the command panel, the thread view) peel before the
// block's own reply/edit below — the caller supplies the order and the
- // edit-in-flight exception (see buildSkeleton's onEscape).
+ // edit-in-flight exception (see buildSkeleton's onEscape). A consumed
+ // Escape is marked handled: with two composer blocks live (the docked
+ // thread pane), the document dispatcher can't tell "this block dealt with
+ // it" from focus alone, and one Escape must peel exactly one layer.
if (e.key === "Escape" && cfg.onEscape && cfg.onEscape(!!editing)) {
+ e.preventDefault();
return;
}
if (e.key === "Escape" && (editing || replyTo)) {
+ e.preventDefault(); // same one-layer rule as above
const wasEditing = !!editing;
cancelReply();
cancelEdit();
@@ -3048,7 +3089,7 @@ function buildComposerBlock(ctx, host, cfg) {
}
}
// ↑ on an empty composer edits your latest still-editable message.
- if (e.key === "ArrowUp" && !composer.getText() && !editing) {
+ if (e.key === "ArrowUp" && !composer.getText() && !editing && !cfg.fixedReply) {
const state = ctx.getState();
const mine = ((state && state.msgs) || [])
.filter((m) => ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName) && m.text.trim() && Date.now() - m.ts < EDIT_WINDOW_MS)
@@ -3312,7 +3353,11 @@ function buildSkeleton(el, ctx) {
hidePanel();
return true;
}
- if (threadSid && !hasEdit) {
+ // Docked, the main composer's staged reply is its OWN state (never the
+ // thread's) — let the block cancel it below before an Escape reaches
+ // for the pane; the next Escape then closes the pane. Overlay, the
+ // staged reply IS the thread's, so closing the view clears it in one.
+ if (threadSid && !hasEdit && !(threadDocked && block.api.stagedReply())) {
closeThread();
return true;
}
@@ -3321,17 +3366,18 @@ function buildSkeleton(el, ctx) {
// While the thread view is open its staged reply IS the view's composer
// state — cancelling it means leaving the thread, so close the whole view
// (which clears the reply) rather than stranding an open thread whose
- // sends would land outside it.
+ // sends would land outside it. Docked, the pane carries its own composer
+ // and the main composer's staged reply is unrelated — just drop it.
onReplyCancel: () => {
- if (!threadSid) return false;
+ if (!threadSid || threadDocked) return false;
closeThread();
return true;
},
// An edit inside an open thread view borrowed the composer (startEdit
// clears the staged reply); hand it back so the next send still continues
- // the thread.
+ // the thread. Docked, the main composer was never staged to the thread.
onEditCancelled: () => {
- if (threadSid) stageThreadReply();
+ if (threadSid && !threadDocked) stageThreadReply();
},
// The composer just grew, shrinking the flex feed. Re-pin to the newest
// message synchronously in the same layout — the ResizeObserver below also
@@ -3350,11 +3396,12 @@ function buildSkeleton(el, ctx) {
},
// Post-send: snap the echo's re-render to the newest message (set now,
// well before the server round-trip; render() clears it), and keep an open
- // thread view's reply staged — sending from it continues the same thread,
- // like iMessage — else the armed reply is spent.
+ // OVERLAY thread view's reply staged — sending from it continues the same
+ // thread, like iMessage — else the armed reply is spent. Docked, the pane
+ // owns the thread's composer, so the main one always just spends its reply.
onSent: () => {
pinNext.add(ctx.threadId);
- if (threadSid) stageThreadReply();
+ if (threadSid && !threadDocked) stageThreadReply();
else block.api.cancelReply();
},
});
@@ -3472,6 +3519,13 @@ function buildSkeleton(el, ctx) {
panelBox.style.removeProperty("transition");
};
const showPanel = (spec) => {
+ // A docked thread and a command panel can't share the right dock (and a
+ // bottom band under an open thread reads as the thread's) — opening a
+ // panel closes the thread, the mirror of openThread's hidePanel(). Gated
+ // 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();
const prev = panelSpec;
panelSpec = spec && typeof spec.render === "function" ? spec : null;
clearPanelEase(); // a mid-animation replace starts clean
@@ -3669,16 +3723,22 @@ function buildSkeleton(el, ctx) {
});
// ---- reply-thread view: tap a quote or a "N Replies" tally to lift that
- // thread over the transcript, iMessage-style ----
- // The overlay occupies the feed's box only (see .chatapp-feedwrap), so the
+ // thread over the transcript (narrow), or dock it as a right-hand column
+ // beside it (desktop widths), Slack-style ----
+ // Overlay: occupies the feed's box only (see .chatapp-feedwrap), so the
// real composer below stays live with its reply state staged to the thread's
- // root — sending from it continues the thread, and the new reply folds into
- // the open view (render() → root._refreshThread re-derives from current
- // state each fold; the view never caches its list).
+ // root — sending from it continues the thread. Docked: the panel parks in
+ // the root's right gutter (the command panel's side-dock technique) and
+ // carries its OWN root-pinned composer, leaving the main one alone. Either
+ // way the new reply folds into the open view (render() → root._refreshThread
+ // re-derives from current state each fold; the view never caches its list).
let threadSid = 0; // the open thread's root sid (0 = closed)
let threadFallback = null; // { id, name, snippet } — header stand-in while the root isn't loaded
- let threadEl = null; // { ov, count, rootBox, note, body } while open
+ let threadEl = null; // { ov, panel, count, rootBox, note, body } while open (ov null when docked)
let threadPaging = false; // paging history backward looking for the root
+ let threadDocked = false; // this open used the docked presentation (decided at open time, like every width check)
+ let threadOpener = null; // the element that opened the view — focus returns there on close
+ let paneBlock = null; // the docked pane's composer block (buildComposerBlock), docked only
// Stage the composer's reply on the thread's root — the whole point of the
// view's composer contract ("Replying to <root author>"). api.stageReply
@@ -3699,27 +3759,48 @@ function buildSkeleton(el, ctx) {
const closeThread = () => {
const sid = threadSid;
if (!sid && !threadEl) return;
+ const docked = threadDocked;
+ const opener = threadOpener;
threadSid = 0;
threadFallback = null;
+ threadDocked = false;
+ threadOpener = null;
if (threadEl) {
- threadEl.ov.remove();
+ if (docked) {
+ threadEl.panel.remove();
+ root.classList.remove("has-thread-pane");
+ } else threadEl.ov.remove();
threadEl = null;
}
- // Leaving the thread drops its staged reply — never an unrelated one staged
- // since (an edit clears the staged reply itself, so that survives untouched).
- const staged = api.stagedReply();
- if (sid && staged && staged.id === sid) api.cancelReply();
+ paneBlock = null; // the pane composer dies with its panel — a re-open builds fresh
+ // Overlay only: leaving the thread drops its staged reply — never an
+ // unrelated one staged since (an edit clears the staged reply itself, so
+ // that survives untouched). Docked, the main composer was never staged to
+ // the thread, so a same-id reply the user armed themselves is theirs to keep.
+ if (!docked) {
+ const staged = api.stagedReply();
+ if (sid && staged && staged.id === sid) api.cancelReply();
+ }
+ // Hand focus back where the view came from (the tally/quote that opened
+ // it), the standard dialog return — but never to a node a re-render has
+ // since replaced. Docked with no usable opener, fall back to the main
+ // composer: closing removed the focused pane, and keyboard/AT users must
+ // not be dropped onto <body>.
+ if (opener && opener.isConnected && typeof opener.focus === "function") opener.focus();
+ else if (docked && !inEmbed && !coarse) composer.focus();
+ // The ▤ Views menu lists an open thread as a view (PR 3) — same repaint
+ // signal the bot views use.
+ document.dispatchEvent(new CustomEvent("lchat:views-changed"));
};
- const buildThread = () => {
- const ov = document.createElement("div");
- ov.className = "chatapp-thread";
- const dim = document.createElement("div");
- dim.className = "chatapp-thread-dim";
- dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread
+ const buildThread = (docked) => {
const panel = document.createElement("div");
- panel.className = "chatapp-thread-panel";
- panel.setAttribute("role", "dialog");
+ panel.className = "chatapp-thread-panel" + (docked ? " chatapp-thread-panel--docked" : "");
+ // Docked it's an ordinary non-modal region beside the chat (a landmark,
+ // no focus trap); the overlay presents as a dialog over the transcript.
+ // No aria-modal on the overlay: its reply surface is the MAIN composer
+ // below the dialog, and aria-modal would tell AT to ignore it.
+ panel.setAttribute("role", docked ? "complementary" : "dialog");
panel.setAttribute("aria-label", "Reply thread");
const head = document.createElement("div");
head.className = "chatapp-thread-head";
@@ -3742,8 +3823,60 @@ function buildSkeleton(el, ctx) {
const body = document.createElement("div");
body.className = "chatapp-thread-msgs";
panel.append(head, rootBox, note, body);
+ if (docked) {
+ // The pane's own composer, pinned to the thread root: a SECOND
+ // buildComposerBlock instance. fixedReply derives the target fresh per
+ // send (the root may load mid-thread — same derivation stageThreadReply
+ // uses), so no banner ever shows and nothing is staged on the main
+ // composer. commands off: "/" text sends verbatim, Slack-style (v1 —
+ // a pane-summoned command panel has nowhere to dock; see the spec's
+ // follow-ups). The whole 4-node fragment mounts INSIDE the wrapper —
+ // including the block's float, which carries the attachment staging
+ // strip and the emoji/@ typeahead drop-up (without it the typeahead
+ // would arm invisibly and swallow Enter). That float is harmless here:
+ // command panels anchor on the MAIN block's float captured in showPanel,
+ // never this one — and nesting keeps the `.chatapp-root >` child
+ // combinators (embed/readonly sweeps) deliberately blind to the pane.
+ const composerWrap = document.createElement("div");
+ composerWrap.className = "chatapp-thread-composer";
+ paneBlock = buildComposerBlock(ctx, host, {
+ fixedReply: () => {
+ const msgs = ((ctx.getState() || {}).msgs) || [];
+ const rootMsg = msgs.find((m) => m.sid === threadSid);
+ return rootMsg
+ ? replyTarget(msgs, rootMsg)
+ : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" };
+ },
+ commands: false,
+ // Escape in the pane's box: an in-flight edit cancels first (return
+ // false — the block's own cancel path runs, then a second Escape
+ // lands back here); otherwise the pane is the layer, so close it.
+ onEscape: (hasEdit) => {
+ if (hasEdit) return false;
+ closeThread();
+ return true;
+ },
+ // Refocus the pane's box so the next reply types straight away (the
+ // factory's own post-send focus covers the common path; this keeps
+ // the contract explicit). Deliberately NO pinNext: the reply shows in
+ // the pane (whose body follows), and snapping the main transcript
+ // would yank a reader who scrolled it while working the thread —
+ // the overlay never had that problem because the dim covers the feed.
+ onSent: () => {
+ if (paneBlock) paneBlock.api.focus();
+ },
+ });
+ composerWrap.appendChild(paneBlock.el);
+ panel.appendChild(composerWrap);
+ return { ov: null, panel, count, rootBox, note, body };
+ }
+ const ov = document.createElement("div");
+ ov.className = "chatapp-thread";
+ const dim = document.createElement("div");
+ dim.className = "chatapp-thread-dim";
+ dim.onclick = () => closeThread(); // tapping the dimmed transcript leaves the thread
ov.append(dim, panel);
- return { ov, count, rootBox, note, body };
+ return { ov, panel, count, rootBox, note, body };
};
// Thread rows reuse the feed's exact bubble machinery — renderItem +
@@ -3843,23 +3976,68 @@ function buildSkeleton(el, ctx) {
if (!api.editing() && staged && staged.id === sid) stageThreadReply();
};
- const openThread = (rootSid, ref) => {
+ const openThread = (rootSid, ref, opener) => {
if (!rootSid) return;
- if (threadSid === rootSid && threadEl) return; // already open on this root
+ if (threadSid === rootSid && threadEl) {
+ // Already open on this root — just put the caret back in the acting
+ // composer (the pane's when docked, the main one under the overlay).
+ if (threadDocked && paneBlock) paneBlock.api.focus();
+ else if (!inEmbed && !coarse) composer.focus();
+ return;
+ }
closeThread();
+ // Presentation is decided per open, at open time — the codebase's width
+ // idiom (point-in-time matchMedia, no live resize listeners). 721px is
+ // the command panel side-dock's breakpoint; below it the overlay is
+ // byte-for-byte today's.
+ threadDocked = matchMedia("(min-width: 721px)").matches;
+ // One right dock: an open command panel yields to a DOCKED thread
+ // (showPanel does the mirror). The overlay never shared a dock with the
+ // panel, so narrow widths keep both — exactly as before.
+ if (threadDocked) hidePanel();
threadSid = rootSid;
threadFallback = ref || null;
- threadEl = buildThread();
- feedWrap.appendChild(threadEl.ov);
- stageThreadReply();
+ threadOpener = opener || null;
+ threadEl = buildThread(threadDocked);
+ if (threadDocked) {
+ // The side-dock technique: the root reserves the gutter with padding
+ // (real layout space), the panel parks absolutely in it — same slot the
+ // command panel's side column uses. Appended LAST deliberately: the
+ // pane carries its own ".composer", and every first-match query in the
+ // tree (closed-DM paint, shell/search/onboarding focus hops) must keep
+ // finding the MAIN composer first; last also puts the pane after the
+ // transcript and composer in Tab order.
+ root.classList.add("has-thread-pane");
+ root.appendChild(threadEl.panel);
+ } else {
+ feedWrap.appendChild(threadEl.ov);
+ // Overlay only: the main composer below IS the thread's reply box, so
+ // stage it to the root. The docked pane's composer needs no staging —
+ // it derives its fixedReply target per send.
+ stageThreadReply();
+ }
// Render before any paging: the view must show what's loaded even when
// pageInRoot bails (e.g. a scroll-back already in flight). When paging does
// run, its own refresh follows synchronously, so no wrong-note flash.
refreshThread();
const msgs = ((ctx.getState() || {}).msgs) || [];
if (!msgs.some((m) => m.sid === rootSid)) pageInRoot();
+ // Move focus into the pane once it's laid out (the rAF lets the insert
+ // paint first). Same touch guard as stageThreadReply's focus: popping the
+ // keyboard over a just-opened pane helps nobody on coarse pointers.
+ if (threadDocked && !inEmbed && !coarse) requestAnimationFrame(() => {
+ if (paneBlock) paneBlock.api.focus();
+ });
+ document.dispatchEvent(new CustomEvent("lchat:views-changed"));
};
- threads.set(ctx.threadId, { open: openThread, close: closeThread, isOpen: () => !!threadSid });
+ threads.set(ctx.threadId, {
+ open: openThread,
+ close: closeThread,
+ isOpen: () => !!threadSid,
+ // The docked pane's composer api, while one is mounted — how setConnected
+ // (and PR 3's composerFor routing) reach the second composer.
+ paneComposer: () => (threadDocked && paneBlock ? paneBlock.api : null),
+ });
// Keyboard shortcuts, mirroring the original feed: with an empty composer,
// cmd/ctrl-T opens the message menu on the latest message from someone else
@@ -3898,16 +4076,19 @@ function buildSkeleton(el, ctx) {
return;
}
// Escape closes an open thread view when the composer isn't focused (the
- // focused case is handled in onComposerKeydown). Skipped when a layer that
- // owns Escape is up — the picker preventDefaults before us (capture phase),
- // the sheet/lightbox are still in the DOM when we run — so one Escape peels
- // one layer, never two.
- if (e.key === "Escape" && threadSid && !api.editing() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ // focused cases — main box and the docked pane's — are handled in each
+ // block's onComposerKeydown, which marks a consumed Escape handled).
+ // Skipped when a layer that owns Escape is up — the picker preventDefaults
+ // before us (capture phase), the sheet/lightbox are still in the DOM when
+ // 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")) {
e.preventDefault();
closeThread();
return;
}
- if (e.key === "Escape" && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) {
+ if (e.key === "Escape" && !e.defaultPrevented && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) {
e.preventDefault();
const wasEditing = api.editing();
api.cancelReply();
diff --git a/web/static/style.css b/web/static/style.css
index 14ced3a..eb93655 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1437,6 +1437,37 @@ select:disabled { opacity: 0.5; cursor: default; }
tally would only point back at where you're standing */
.chatapp-thread .reply-quote,
.chatapp-thread .reply-count { 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
+ thread-owned class names. The root reserves the gutter with padding so the
+ column takes real layout space; the pane carries its own root-pinned
+ composer at the bottom (.chatapp-thread-composer). Solid var(--panel), not
+ the overlay's frosted glass — nothing shows through a flush column — with
+ the frosted @supports rule above (same specificity) overridden by source
+ order; light-dark() tokens hold both themes for free. Below 721px these
+ rules stand down and a still-mounted docked panel degrades to an in-flow
+ 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) */
+@media (min-width: 721px) {
+ .chatapp-root.has-thread-pane { padding-right: min(340px, 40%); }
+ .chatapp-thread-panel--docked {
+ position: absolute; top: 0; right: 0; bottom: 0; width: min(340px, 40%);
+ margin: 0; border-radius: 0; box-shadow: none;
+ border: none; border-left: 1px solid var(--border);
+ background: var(--panel);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+ .chatapp-thread-composer { flex: 0 0 auto; border-top: 1px solid var(--border); }
+}
+@media (pointer: coarse) {
+ /* long-press opens the message menu inside the pane too — suppress the
+ native selection/callout the same way the overlay's bubbles do */
+ .chatapp-thread-panel--docked .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
+}
/* composer "Replying to …" / "Editing message" banners, above the composer
(and any attachment preview). They share the .reply-bar inner markup. */