eleven / ocho Public

web: extract buildComposerBlock — composer as an instantiable unit (#1462)

Pure refactor of buildSkeleton (apps/chat.js), zero behaviour change: the
inline composer — banners, attach/upload staging, typeahead, the plain/rich
input facade, send, and the submit handler — moves into a new
buildComposerBlock(ctx, host, cfg) factory, so the docked thread pane (next
PR) can instantiate a second, root-pinned composer beside the main one.

buildSkeleton is the factory's only caller ({ fixedReply: null, commands:
true, inEmbed }). Everything per-pane stays in buildSkeleton, wired through
cfg hooks and the returned api: the composers.set registration (still one
entry), the command panel, the thread view (stageThreadReply / closeThread /
pageInRoot now drive the block via api.stageReply / stagedReply / cancelReply),
root._hotkeys, root._native, the feed-follow pins (onFieldGrow /
onStripChange), and the post-send pinNext + thread re-stage (onSent — today's
`if (threadSid) stageThreadReply(); else cancelReply();` verbatim). The
labelled-ask/hint machinery and deliverAnswer stay primary-surface-only,
gated on fixedReply == null, so the single caller's behaviour is byte-
identical. The block's four top-level nodes return as a fragment (siblings,
never wrapped) because the .embed/.readonly rules match them with
`.chatapp-root > .composer` child combinators — no CSS touched.

No exported signatures change; npm test (event vectors included) green.


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 5f9ce9fbfbbb58c8f43a165f442b5d625b628ea4 parent 2480c1a
1 file changed, +743 −572
  • web/static/apps/chat.js +743 −572
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 37417d2..fdfa5b2 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -2389,43 +2389,53 @@ function addLinkPreview(content, url) {
});
}
-// ---- skeleton: feed, banners, staging, composer (original markup) -----------
-
-// buildSkeleton constructs the once-per-mount frame with the original composer
-// structure and class names — .composer-reply / .composer-edit banners, the
-// floating .composer-preview staging strip, and the .composer form — so
-// style.css styles them identically to the original ones (which are hidden in
-// app-mode). Send emits the original untyped payload; editing re-sends the full
-// original payload with new text through the host bridge.
-function buildSkeleton(el, ctx) {
- const root = document.createElement("div");
- root.className = "chatapp-root";
-
-
- const feed = document.createElement("div");
- feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout
- // The wrapper bounds the reply-thread overlay to exactly the feed's box (it
- // dims the transcript, never the composer). Feed selectors everywhere are
- // depth-independent (.chatapp-feed querySelectors, no child combinators), so
- // the extra level is invisible to the rest of the shell + native probes.
- const feedWrap = document.createElement("div");
- feedWrap.className = "chatapp-feedwrap";
- feedWrap.appendChild(feed);
-
- // Jump-to-latest: the classic chat affordance for getting back to the
- // newest message without hunting for it — shown/hidden by the feed's
- // "scroll" listener below (and re-checked after each render, since new
- // messages can widen the gap without a scroll event).
- const jumpBtn = document.createElement("button");
- jumpBtn.type = "button";
- jumpBtn.className = "feed-jump-btn hidden";
- // aria-label carries the accessible name; title mirrors it as a mouse-hover
- // tooltip (icon-only button — see docs/accessibility.md's icon-label rule).
- jumpBtn.setAttribute("aria-label", "Jump to latest");
- jumpBtn.title = "Jump to latest";
- jumpBtn.innerHTML = icon("chevronDown");
- jumpBtn.onclick = () => feedGlideToBottom(feed, false);
- feedWrap.appendChild(jumpBtn);
+// ---- composer block: banners, staging, typeahead, input, send ---------------
+
+// buildComposerBlock builds one complete composer — the reply/edit banners,
+// the floating preview/typeahead anchor, attach + upload staging, the
+// plain/rich input facade, and send — as an instantiable unit. It exists so
+// the docked reply-thread pane (next PR) can mount a SECOND composer beside
+// the main one; today buildSkeleton is its only caller, and the extraction
+// changes no behaviour. The block owns its own reply/edit/staging state;
+// everything per-PANE — the command panel, the thread view, the feed and its
+// follow behaviour, the composers registry, hotkeys, and the native bridge —
+// stays with the caller, wired through the cfg hooks and the returned api.
+//
+// cfg:
+// fixedReply null = the main composer: replies stage freely and banners
+// show. Or () => {id,name,snippet}: the block is pinned to a
+// thread root — the target derives fresh per send, no reply
+// banner or cancel ever shows, and the block stands down from
+// the primary-surface machinery (labelled asks, composer
+// hints, deliverAnswer), which targets the main composer only.
+// inEmbed the native shell draws the visible composer (host.native
+// mirrors banners/staging; this block is hidden by CSS).
+// commands false = no command routing at all (slash, /me, @app
+// mentions, default commands) — text sends verbatim.
+// onSent(payload) a send reached the server (the caller's scroll pin +
+// thread re-stage — see compose.onsubmit).
+// onEscape(hasEdit) pane-owned Escape layers; return true when consumed.
+// onReplyCancel() the reply banner's ✕; return true when the pane
+// consumed it (closing an open thread view) — otherwise
+// the block just drops its staged reply.
+// onEditCancelled() an edit ended — the pane may re-stage a thread reply.
+// onFieldGrow() the input grew this keystroke (caller re-pins its feed).
+// onStripChange() the staging strip appeared/changed (caller keeps the
+// feed's newest content in view).
+//
+// Returns { el, float, composer, api }. `el` is a fragment of the block's
+// four top-level nodes — kept as SIBLINGS under the caller's root, never
+// wrapped, because the .embed/.readonly rules hide composer surfaces with
+// `.chatapp-root > .composer` child combinators. `float` is the zero-height
+// anchor command panels insert before; `composer` is the input facade; `api`
+// carries the verbs the caller wires outward (the composers registry, the
+// thread view, hotkeys, the native bridge).
+function buildComposerBlock(ctx, host, cfg) {
+ const inEmbed = !!cfg.inEmbed;
+ // The primary (main) composer is the one surface the labelled-ask/hint
+ // machinery and scripted answers (deliverAnswer) target; a root-pinned
+ // block never is.
+ const primary = !cfg.fixedReply;
const float = document.createElement("div");
float.className = "composer-float";
@@ -2488,7 +2498,7 @@ function buildSkeleton(el, ctx) {
};
labelDismiss.addEventListener("click", dismissComposerAsk);
const refreshLabelDismiss = () => {
- labelDismiss.hidden = !(composerLabels.get(ctx.threadId) || "") || !send.hidden;
+ labelDismiss.hidden = !primary || !(composerLabels.get(ctx.threadId) || "") || !send.hidden;
};
// When plain text runs the app's default command (typing into To-do adds a
// to-do — see defaultCommandFor), the placeholder must SAY so: the implicit
@@ -2503,16 +2513,15 @@ function buildSkeleton(el, ctx) {
// can slot in behind the same handle. The handler bodies are function
// declarations further down (hoisted; they run long after the skeleton is
// fully built, so the consts they close over are all live by then).
- const inEmbed = !!(host && host.embed);
const composerOpts = {
- placeholder: composerHints.get(ctx.threadId) || basePlaceholder(),
+ placeholder: primary ? composerHints.get(ctx.threadId) || basePlaceholder() : "Reply…",
formatAsYouType: formatAsYouTypePref(),
onInput: () => onComposerInput(),
onKeydown: (e) => {
// Escape dismisses an active labelled ask (#1375) — the keyboard twin
// of the X. Only while the pill shows; otherwise Escape keeps its
// usual meanings (close typeahead, cancel edit…) downstream.
- if (e.key === "Escape" && (composerLabels.get(ctx.threadId) || "")) {
+ if (e.key === "Escape" && primary && (composerLabels.get(ctx.threadId) || "")) {
e.preventDefault();
dismissComposerAsk();
return;
@@ -2541,6 +2550,7 @@ function buildSkeleton(el, ctx) {
pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref()
? adoptComposer(pmModule.createRichComposer(composerOpts))
: createComposer(composerOpts);
+
const applyFieldLabel = () => {
const label = composerLabels.get(ctx.threadId) || "";
fieldLabel.textContent = label;
@@ -2556,11 +2566,17 @@ function buildSkeleton(el, ctx) {
if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8);
});
};
- refreshComposerHint.set(ctx.threadId, () => {
- const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
- composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder());
- applyFieldLabel();
- });
+ // The labelled-ask / composer-hint machinery targets the PRIMARY surface
+ // only — a second (root-pinned) block never registers, so a scripted
+ // question can't relabel a thread pane's box.
+ if (primary) {
+ refreshComposerHint.set(ctx.threadId, () => {
+ const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
+ composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder());
+ applyFieldLabel();
+ });
+ }
+
const send = document.createElement("button");
send.type = "submit";
send.className = "send-btn";
@@ -2575,59 +2591,20 @@ function buildSkeleton(el, ctx) {
// drop the same way).
attach.hidden = !!defaultCmd();
-
- root.append(feedWrap, float, replyBanner, editBanner, compose);
- el.appendChild(root);
-
- // Keep the feed pinned to the newest message when the composer, its staged
- // reply/edit banner, or the preview strip grows and steals vertical space —
- // they're in-flow siblings, so the flex feed shrinks and this observer fires.
- // (The original re-pins the same way; render() alone can't, since a resize
- // isn't a state change.) Track "at bottom" from real scrolls so the decision
- // reflects the state *before* the feed shrank — reading the gap inside the
- // callback would already show it past the threshold and wrongly bail. The
- // `< 60` matches render()'s pin check.
- let feedAtBottom = true;
- const updateFeedScrollState = () => {
- const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight;
- feedAtBottom = gap < 60;
- jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP);
- };
- feed.addEventListener("scroll", updateFeedScrollState, { passive: true });
- // render() re-checks after every message-list update too — new messages
- // widen the gap (or a glide-to-bottom closes it) without firing "scroll".
- root._updateJumpBtn = updateFeedScrollState;
- new ResizeObserver(() => {
- if (!feedAtBottom) return;
- // WebKit restores the pre-resize scrollTop at *layout* time — which lands
- // after this callback AND after a single rAF — so one re-assert loses the
- // race and strands the feed the growth-delta (~one composer line) above the
- // bottom (Chrome doesn't do this). Re-pin across a few frames so a
- // correction always lands after the restoration, whenever it happens. The
- // scroll listener keeps feedAtBottom true through the restoration (its gap
- // stays under the 60px threshold), so this bails only if the reader
- // genuinely scrolls up mid-window.
- let frame = 0;
- const pin = () => {
- if (!feedAtBottom) return; // reader scrolled away — stop fighting them
- feed.scrollTop = feed.scrollHeight;
- if (++frame < 4) requestAnimationFrame(pin);
- };
- pin();
- }).observe(feed);
-
- // ---- composer state (per mount; a fresh open rebuilds it clean) ----
+ // ---- composer state (per block; a fresh open rebuilds it clean) ----
let replyTo = null; // { id, name, snippet } — the original payload shape
let editing = null; // the item being edited
let editBaseline = ""; // getMarkdown() right after edit-load (the ↓-undo comparator)
let pending = []; // staged attachments: { att | null (uploading), name, isImage }
let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish
let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch
- // A remount mid-conversation must restore the inset label; it needs layout
- // (the width measure) and `pending` above, so it runs here, post-append.
- applyComposerHint(ctx.threadId);
+ // A remount mid-conversation must restore the inset label. The caller
+ // appends the block in the same tick, and the one part that needs layout
+ // (the width measure in applyFieldLabel) is rAF-deferred past that insert.
+ if (primary) applyComposerHint(ctx.threadId);
const refreshBanners = () => {
+ if (cfg.fixedReply) return; // a root-pinned block derives its reply per send — no banner
replyBanner.textContent = "";
replyBanner.classList.add("hidden");
editBanner.textContent = "";
@@ -2661,21 +2638,20 @@ function buildSkeleton(el, ctx) {
x.className = "reply-bar-cancel";
x.innerHTML = icon("x");
x.setAttribute("aria-label", "Cancel reply");
- // 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.
- x.onclick = () => (threadSid ? closeThread() : cancelReply());
+ // The pane may own this gesture (an open thread view treats its staged
+ // reply as the view's own state — see buildSkeleton's onReplyCancel);
+ // otherwise it just drops the staged reply.
+ x.onclick = () => {
+ if (!(cfg.onReplyCancel && cfg.onReplyCancel())) cancelReply();
+ };
bar.append(ic, body, x);
replyBanner.appendChild(bar);
replyBanner.classList.remove("hidden");
}
};
- if (inEmbed) root.classList.add("embed"); // the native shell draws the composer
- // The owning app declared its chat a read-only stream — no composer to draw.
- if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly");
const cancelReply = () => {
+ if (cfg.fixedReply) return; // nothing staged to cancel — the reply derives per send
replyTo = null;
refreshBanners();
if (inEmbed) host.native({ replying: null });
@@ -2684,19 +2660,19 @@ function buildSkeleton(el, ctx) {
editing = null;
refreshBanners();
if (inEmbed) host.native({ editing: null });
- // An edit inside an open thread view borrowed the composer (startEdit clears
- // replyTo); hand it back so the next send still continues the thread.
- if (threadSid) stageThreadReply();
+ // The pane may need to re-arm state the edit displaced (an open thread
+ // view's staged reply — see buildSkeleton's onEditCancelled).
+ if (cfg.onEditCancelled) cfg.onEditCancelled();
};
// Staged attachments as original .preview-chip tiles: a spinner while
// uploading, then a thumbnail (image) or a file chip, each with its remove.
const refreshStaged = () => {
if (inEmbed) host.native({ staged: pending.length > 0 }); // enables the native send button
- applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files
- // The strip takes real layout space, so the feed shrinks when it appears —
- // keep the newest messages (and any action chips) in view, not under the fold.
- if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
+ if (primary) applyComposerHint(ctx.threadId); // an attach-gated hint appears/disappears with the staged files
+ // The strip takes real layout space — the caller keeps its feed pinned
+ // (see buildSkeleton's onStripChange).
+ if (cfg.onStripChange) cfg.onStripChange();
preview.textContent = "";
preview.classList.toggle("hidden", pending.length === 0);
for (const p of pending) {
@@ -2795,29 +2771,6 @@ function buildSkeleton(el, ctx) {
compose.requestSubmit();
};
- composers.set(ctx.threadId, {
- refreshSend,
- startReply(m) {
- editing = null;
- // Replying to a reply continues its thread: target the TOP message.
- replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m);
- refreshBanners();
- // Embedded: the native shell draws the reply banner and focuses its own
- // composer (this one is hidden).
- if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
- else composer.focus();
- },
- startEdit(m) {
- replyTo = null;
- editing = m;
- composer.setMarkdown(m.text, { notify: true }); // resize + reveal send
- editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes
- refreshBanners();
- if (inEmbed) host.native({ editing: { text: m.text } });
- if (!inEmbed) composer.focus();
- },
- });
-
// Matches maxUploadBytes (main.go) / the original composer's MAX_UPLOAD — used
// only to word the "too big" message; the host bridge does the real limiting.
const MAX_UPLOAD = 25 * 1024 * 1024;
@@ -2889,327 +2842,93 @@ function buildSkeleton(el, ctx) {
stageFiles(files);
};
- // ---- command panel: the transient results surface a command opens ----
- // (ctx.panel — see the command-panels section of apps.js.) In-flow, never
- // floating: it sits above the composer stack by default, or pinned above the
- // feed with position: "top", taking layout space either way. One per thread —
- // opening another replaces it; its ✕, Escape, or the app's panel.close()
- // dismisses it. Client-only chrome: nothing here is sent or folded.
- const panelBox = document.createElement("section");
- panelBox.className = "cmd-panel hidden";
- panelBox.setAttribute("role", "region");
- let panelSpec = null;
- const panelOpen = () => !!panelSpec;
- const renderPanelBody = (spec, body) => {
- body.replaceChildren();
- // The handle a panel renders against: dismiss it, redraw it, and act on
- // the composer it sits above — insert text at the caret, or stage
- // attachments through the same path the attach button / paste / drop use.
- const panel = {
- threadId: ctx.threadId,
- close: () => hidePanel(),
- refresh: () => {
- if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body);
- },
- insertText: (t) => {
- const text = String(t == null ? "" : t);
- if (!text) return;
- // Embedded: the native shell owns the visible composer — forward the
- // insertion (a shell that doesn't handle composerInsert ignores it).
- if (inEmbed) return host.native({ composerInsert: text });
- composer.insertAtCaret(text);
- },
- attachFiles: (files) => stageFiles([...(files || [])]),
- };
- try {
- spec.render(body, panel);
- } catch (e) {
- // A panel must never take down the pane — contain the error in place.
- console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e);
- body.replaceChildren();
- const p = document.createElement("p");
- p.textContent = "This panel hit an error and couldn't load.";
- body.appendChild(p);
+ function onComposerPaste(e) {
+ const files = [...((e.clipboardData && e.clipboardData.files) || [])];
+ if (files.length) {
+ e.preventDefault();
+ stageFiles(files);
+ }
+ // Blockquote continuation: pasting multi-line text onto a bare "> " line
+ // prefixes every line after the first with "> " so the whole quote stays a
+ // quote — matches the original composer's paste behaviour.
+ if (!files.length && composer.kind === "plain") {
+ const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
+ if (textItem) {
+ const start = composer.getCaret();
+ const beforeCursor = composer.getText().substring(0, start);
+ const currentLine = beforeCursor.split("\n").pop();
+ if (/^>\s*$/.test(currentLine)) {
+ e.preventDefault();
+ textItem.getAsString((pastedText) => {
+ const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n");
+ composer.replaceRange(start, composer.getSelectionEnd(), blockquoted);
+ });
+ }
+ }
}
+ }
+
+ // ---- :emoji / @mention typeahead — the original menu, verbatim ----
+ // composerToken (pure) detects the in-progress token at the caret; the host
+ // ranks the candidates (host.typeaheadItems — the same function that feeds
+ // the original menu and the native strip, so all three rank identically).
+ let typeahead = null; // { kind, start, items, index } or null
+ const closeTypeahead = () => {
+ typeahead = null;
+ taBox.classList.add("hidden");
+ taBox.innerHTML = "";
};
- // Whether the panel eases open/shut: the app's call (animate: false opts
- // out), gated on reduced-motion — and skipped in the command-host embed,
- // where the panel is the whole page and the NATIVE shell animates the
- // frame instead (an in-page ease inside a just-revealed frame reads as a
- // flash, not a slide).
- // (The side column skips the ease outright — its reveal is horizontal, and
- // a height roll inside a full-height column reads as a glitch.)
- const panelEases = (spec) =>
- spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host");
- const clearPanelEase = () => {
- panelBox.style.removeProperty("height");
- panelBox.style.removeProperty("overflow");
- panelBox.style.removeProperty("transition");
+ const acceptTypeahead = () => {
+ if (!typeahead) return;
+ const it = typeahead.items[typeahead.index];
+ const caret = composer.getCaret();
+ const start = typeahead.start;
+ closeTypeahead();
+ // replaceRange notifies (resize + send-visibility + token re-check) and
+ // restores focus — the original splice, behind the facade.
+ composer.replaceRange(start, caret, it.insert);
};
- const showPanel = (spec) => {
- const prev = panelSpec;
- panelSpec = spec && typeof spec.render === "function" ? spec : null;
- clearPanelEase(); // a mid-animation replace starts clean
- // Replacing (or closing) a panel tells the outgoing one — best-effort.
- if (prev && prev.onClose) {
- try {
- prev.onClose();
- } catch (e) {
- console.error(e);
- }
- }
- panelBox.replaceChildren();
- panelBox.classList.toggle("hidden", !panelSpec);
- panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top"));
- panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side"));
- // The side placement reserves a right-hand gutter on the root (padding,
- // so the column occupies real layout space and nothing renders under it).
- root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side"));
- if (!panelSpec) {
- notifyCommandPanel(ctx.threadId, null);
- return;
- }
- // Every placement is in-flow: "top" pins it above the feed (under the
- // shared-app surface); "side" is a full-height right column (a vertical
- // sidebar — list-shaped content wants a tall, narrow box, not a wide,
- // short band; on narrow screens its CSS falls back to the bottom band);
- // the default sits at the bottom of the pane, above the composer stack
- // (before the zero-height .composer-float anchor).
- root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : float);
- const head = document.createElement("div");
- head.className = "cmd-panel-head";
- const s = panelSpec.sender || {};
- if (s.glyph) {
- const g = document.createElement("span");
- g.textContent = s.glyph;
- head.appendChild(g);
- }
- const title = document.createElement("span");
- title.className = "cmd-panel-title";
- title.textContent = panelSpec.title || s.name || "";
- const x = document.createElement("button");
- x.type = "button";
- x.className = "cmd-panel-x";
- x.setAttribute("aria-label", "Dismiss");
- x.innerHTML = icon("x");
- x.onclick = () => hidePanel();
- // headExtra: a spec-provided slot in the title row, between the title and
- // the dismiss — for a panel's one standing control (Memory's "Forget…"),
- // so it sits inline with the name instead of eating body space.
- if (typeof panelSpec.headExtra === "function") {
- const extra = document.createElement("span");
- extra.className = "cmd-panel-extra";
- try {
- panelSpec.headExtra(extra);
- } catch (e) {
- console.error(e);
- }
- head.append(title, extra, x);
- } else {
- head.append(title, x);
- }
- const body = document.createElement("div");
- body.className = "cmd-panel-body";
- panelBox.append(head, body);
- renderPanelBody(panelSpec, body);
- notifyCommandPanel(ctx.threadId, panelSpec);
- // Ease up from nothing to the panel's natural height (see panelEases).
- if (panelEases(panelSpec)) {
- const h = panelBox.offsetHeight;
- if (h) {
- panelBox.style.overflow = "hidden";
- panelBox.style.height = "0px";
- void panelBox.offsetHeight; // commit the start height
- panelBox.style.transition = "height 0.22s ease";
- panelBox.style.height = h + "px";
- panelBox.addEventListener("transitionend", clearPanelEase, { once: true });
- setTimeout(clearPanelEase, 350); // backstop if transitionend never fires
- }
- }
- // A bottom panel takes its space from the feed — keep the newest in view.
- if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
- };
- // Dismissal rolls the panel down before tearing it out, unless the app (or
- // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕)
- // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation
- // wins — showPanel resets the inline styles, and the captured spec check
- // keeps the stale finish() from tearing the newcomer down.
- let panelClosing = false;
- const hidePanel = () => {
- if (!panelSpec || panelClosing) return;
- if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null);
- panelClosing = true;
- const closing = panelSpec;
- let finished = false;
- const finish = () => {
- if (finished) return;
- finished = true;
- panelClosing = false;
- clearPanelEase();
- if (panelSpec === closing) showPanel(null);
- };
- const startH = panelBox.offsetHeight;
- if (!startH) return finish();
- panelBox.style.overflow = "hidden";
- panelBox.style.height = startH + "px";
- void panelBox.offsetHeight;
- panelBox.style.transition = "height 0.2s ease";
- panelBox.style.height = "0px";
- panelBox.addEventListener("transitionend", finish, { once: true });
- setTimeout(finish, 320);
- };
- // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
- cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
- function onComposerPaste(e) {
- const files = [...((e.clipboardData && e.clipboardData.files) || [])];
- if (files.length) {
- e.preventDefault();
- stageFiles(files);
- }
- // Blockquote continuation: pasting multi-line text onto a bare "> " line
- // prefixes every line after the first with "> " so the whole quote stays a
- // quote — matches the original composer's paste behaviour.
- if (!files.length && composer.kind === "plain") {
- const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
- if (textItem) {
- const start = composer.getCaret();
- const beforeCursor = composer.getText().substring(0, start);
- const currentLine = beforeCursor.split("\n").pop();
- if (/^>\s*$/.test(currentLine)) {
- e.preventDefault();
- textItem.getAsString((pastedText) => {
- const blockquoted = pastedText.split("\n").map((line, i) => i === 0 ? line : "> " + line).join("\n");
- composer.replaceRange(start, composer.getSelectionEnd(), blockquoted);
- });
- }
- }
- }
- }
- // Drag-drop overlay affordance (.drop-overlay — the same class the original
- // pane uses, so it looks identical). The overlay element exists in *both* modes;
- // only the web drop *listeners* are web-only (see below).
- const dropOverlay = document.createElement("div");
- dropOverlay.className = "drop-overlay hidden";
- dropOverlay.textContent = "Drop a photo or file to send";
- root.appendChild(dropOverlay);
- // The native macOS shell (Eleven) drives the overlay during a Finder drag:
- // WebKit swallows the drag events natively (see the listener note below), so the
- // page's own dragenter/leave never fire in the app. Selector-based so it always
- // targets the connected root's overlay — survives thread switches / the
- // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.)
- window.__lchatDropOverlay = (show) => {
- const o = document.querySelector(".chatapp-root .drop-overlay");
- if (o) o.classList.toggle("hidden", !show);
- };
- // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't
- // hand the page the dropped files on macOS, and merely *registering* a web drop
- // handler makes WebKit claim the drop, bypassing the native path, so nothing
- // uploads. So skip the web drop listeners in the native app (html.native); there
- // the shell delivers files via __lchatAddFiles and shows the overlay via
- // __lchatDropOverlay above.
- if (!document.documentElement.classList.contains("native")) {
- let dragDepth = 0;
- root.addEventListener("dragenter", (e) => {
- if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
- dragDepth++;
- dropOverlay.classList.remove("hidden");
- });
- root.addEventListener("dragleave", () => {
- if (--dragDepth <= 0) {
- dragDepth = 0;
- dropOverlay.classList.add("hidden");
- }
- });
- root.addEventListener("dragover", (e) => {
- if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault();
- });
- root.addEventListener("drop", (e) => {
- dragDepth = 0;
- dropOverlay.classList.add("hidden");
- const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
- if (files.length) {
- e.preventDefault();
- stageFiles(files);
- }
- });
- }
- // The native macOS shell (LChatMac) can't hand Finder file-drops to the page —
- // a WKWebView doesn't deliver them and the web drop listeners above are skipped
- // in native mode — so it fills the page's static #file-input and dispatches
- // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its
- // own input, so expose a handler that pulls the files off that static input
- // into this thread's staging path; render() wires it to #file-input for the
- // thread on screen. (A plain browser never reaches this — its drops land on the
- // web listeners above.)
- root._dropFiles = () => {
- const el = document.getElementById("file-input");
- if (!el) return;
- const files = [...(el.files || [])];
- el.value = "";
- if (files.length) stageFiles(files);
- };
-
- // ---- :emoji / @mention typeahead — the original menu, verbatim ----
- // composerToken (pure) detects the in-progress token at the caret; the host
- // ranks the candidates (host.typeaheadItems — the same function that feeds
- // the original menu and the native strip, so all three rank identically).
- let typeahead = null; // { kind, start, items, index } or null
- const closeTypeahead = () => {
- typeahead = null;
- taBox.classList.add("hidden");
- taBox.innerHTML = "";
- };
- const acceptTypeahead = () => {
- if (!typeahead) return;
- const it = typeahead.items[typeahead.index];
- const caret = composer.getCaret();
- const start = typeahead.start;
- closeTypeahead();
- // replaceRange notifies (resize + send-visibility + token re-check) and
- // restores focus — the original splice, behind the facade.
- composer.replaceRange(start, caret, it.insert);
- };
- const renderTa = () => {
- if (!typeahead) return;
- taBox.innerHTML = "";
- typeahead.items.forEach((it, i) => {
- const row = document.createElement("button");
- row.type = "button";
- row.className = "ta-item" + (i === typeahead.index ? " active" : "");
- row.setAttribute("role", "option");
- row.setAttribute("aria-selected", String(i === typeahead.index));
- if (typeahead.kind === "command") {
- const n = document.createElement("span");
- n.className = "ta-name";
- n.textContent = it.command + (it.args ? " " + it.args : "");
- row.appendChild(n);
- if (it.desc) {
- const d = document.createElement("span");
- d.className = "ta-desc";
- d.textContent = it.desc;
- row.appendChild(d);
- }
- } else if (typeahead.kind === "emoji") {
- const g = document.createElement("span");
- g.className = "ta-emoji";
- g.textContent = it.glyph;
- const c = document.createElement("span");
- c.className = "ta-code";
- c.textContent = ":" + it.code + ":";
- row.append(g, c);
- } else {
- // A group-added app in the @ typeahead wears its glyph avatar (there's
- // no roster photo to load); people keep the original avatar element.
- if (it.glyph) {
- const g = document.createElement("span");
- g.className = "avatar app-avatar";
- g.textContent = it.glyph;
- row.appendChild(g);
- } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
- const n = document.createElement("span");
- n.className = "ta-name";
- n.textContent = it.name;
- row.appendChild(n);
+ const renderTa = () => {
+ if (!typeahead) return;
+ taBox.innerHTML = "";
+ typeahead.items.forEach((it, i) => {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "ta-item" + (i === typeahead.index ? " active" : "");
+ row.setAttribute("role", "option");
+ row.setAttribute("aria-selected", String(i === typeahead.index));
+ if (typeahead.kind === "command") {
+ const n = document.createElement("span");
+ n.className = "ta-name";
+ n.textContent = it.command + (it.args ? " " + it.args : "");
+ row.appendChild(n);
+ if (it.desc) {
+ const d = document.createElement("span");
+ d.className = "ta-desc";
+ d.textContent = it.desc;
+ row.appendChild(d);
+ }
+ } else if (typeahead.kind === "emoji") {
+ const g = document.createElement("span");
+ g.className = "ta-emoji";
+ g.textContent = it.glyph;
+ const c = document.createElement("span");
+ c.className = "ta-code";
+ c.textContent = ":" + it.code + ":";
+ row.append(g, c);
+ } else {
+ // A group-added app in the @ typeahead wears its glyph avatar (there's
+ // no roster photo to load); people keep the original avatar element.
+ if (it.glyph) {
+ const g = document.createElement("span");
+ g.className = "avatar app-avatar";
+ g.textContent = it.glyph;
+ row.appendChild(g);
+ } else if (host && host.taAvatar) row.appendChild(host.taAvatar(it.name, it.userId));
+ const n = document.createElement("span");
+ n.className = "ta-name";
+ n.textContent = it.name;
+ row.appendChild(n);
}
// pointerdown fires before the textarea blurs, so the tap isn't lost.
row.addEventListener("pointerdown", (e) => {
@@ -3276,16 +2995,12 @@ function buildSkeleton(el, ctx) {
if (now - lastBeat >= TYPING_INTERVAL_MS) stopTypingBeat(); // idle since the last keystroke
else if (now - lastEmit >= TYPING_INTERVAL_MS) sendBeat(TYPING_EXPIRY_S); // sustain the indicator
};
- composers.get(ctx.threadId).tickTyping = tickTyping; // render() re-arms the ticker each draw
+
function onComposerInput() {
// (Auto-grow already ran inside the facade.) The composer just grew,
- // shrinking the flex feed. Re-pin to the newest message *here*,
- // synchronously in the same layout — the ResizeObserver below also
- // re-pins, but only on a later frame, which leaves one painted frame
- // stranded a line short of the bottom (the visible "jump" on WebKit / the
- // Mac app). Guard on feedAtBottom so typing never yanks a reader who
- // scrolled up.
- if (feedAtBottom) feed.scrollTop = feed.scrollHeight;
+ // shrinking the caller's flex feed — let it re-pin synchronously in this
+ // same layout (see buildSkeleton's onFieldGrow for the WebKit why).
+ if (cfg.onFieldGrow) cfg.onFieldGrow();
if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them
refreshSend();
updateTypeahead();
@@ -3317,17 +3032,10 @@ function buildSkeleton(el, ctx) {
setSendQueued(false); // un-queue first; a second Escape cancels reply/edit
return;
}
- // An open command panel is the most recently summoned layer — Escape peels
- // it before the thread view / reply / edit below.
- if (e.key === "Escape" && panelOpen()) {
- hidePanel();
- return;
- }
- // An open thread view closes on Escape (dropping its staged reply) — unless
- // an edit is in flight, which the branch below cancels first (cancelEdit
- // re-stages the thread reply, so a second Escape then closes the view).
- if (e.key === "Escape" && threadSid && !editing) {
- closeThread();
+ // 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).
+ if (e.key === "Escape" && cfg.onEscape && cfg.onEscape(!!editing)) {
return;
}
if (e.key === "Escape" && (editing || replyTo)) {
@@ -3347,7 +3055,10 @@ function buildSkeleton(el, ctx) {
.pop();
if (mine) {
e.preventDefault();
- composers.get(ctx.threadId).startEdit(mine);
+ // This block's own verb — the registry entry is the same function for
+ // the main composer, and a second block must edit in ITS box, not the
+ // registered one's.
+ api.startEdit(mine);
}
}
// ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line
@@ -3378,12 +3089,14 @@ function buildSkeleton(el, ctx) {
composer.clear();
refreshSend();
stopTypingBeat(); // command / edit / send all clear the field — end the beat now
- clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply
+ if (primary) clearComposerHint(ctx.threadId); // the question was answered — back to "Message" now, not at the reply
};
+ // Command routing below is gated on cfg.commands — a block with commands
+ // off sends every line verbatim, "/" text included (the pane composer).
// /me is the one command that BROADCASTS a real message — an action-style
// line ("Alice waves"), not a bubble — instead of answering privately, so
// it's handled here rather than through runCommand (see parseMeCommand).
- if (!editing) {
+ if (cfg.commands && !editing) {
const action = parseMeCommand(plain);
if (action !== null) {
if (!action) return resetInput(); // bare "/me" — nothing to say
@@ -3395,88 +3108,546 @@ function buildSkeleton(el, ctx) {
return;
}
}
- // A slash command (mini-app command, /help, or an unknown "/foo") is handled
- // instead of sending — it answers in place with a private "only visible to you"
- // reply that stacks in the feed and persists (addLocalNotice). Skipped while
- // editing, like the original composer.
- if (!editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
- resetInput();
+ // A slash command (mini-app command, /help, or an unknown "/foo") is handled
+ // instead of sending — it answers in place with a private "only visible to you"
+ // reply that stacks in the feed and persists (addLocalNotice). Skipped while
+ // editing, like the original composer.
+ if (cfg.commands && !editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
+ resetInput();
+ return;
+ }
+ // "@To-do buy milk" in a chat with apps added: the mention addresses the
+ // app, the text runs its default command — an EVENT into this thread (shown
+ // as an action line), not a message. Leading-@ only; a mention mid-text is
+ // ordinary conversation. See runAppMention.
+ if (cfg.commands && !editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
+ resetInput();
+ return;
+ }
+ // Plain text in a default-command thread runs the app's default command
+ // instead of sending a message nobody reads — typing "buy milk" into To-do
+ // just adds it (defaultCommandFor gates this to app-threads with no bot;
+ // slash text never falls through here, a "/" always means command).
+ if (cfg.commands && !editing && !replyTo && plain && plain[0] !== "/" && !pending.length) {
+ const dc = defaultCmd();
+ if (dc) {
+ const typed = plain;
+ runCommand(dc.name + " " + typed, ctx.threadId, (r) => {
+ addLocalNotice(ctx, r);
+ // A reply with no `short` is the command PUSHING BACK ("when should
+ // I remind you?"), not a confirmation — put the typed text back so
+ // it can be fixed, not retyped (matches the native .rejected path).
+ if (r && !r.short && !composer.getText()) {
+ composer.setText(typed);
+ refreshSend();
+ }
+ });
+ resetInput();
+ return;
+ }
+ }
+ if (editing) {
+ // An empty edit just cancels; otherwise only clear once the edit actually
+ // reaches the server — host.edit resolves false on a down socket, so the
+ // typed changes survive to be resent after reconnecting.
+ if (!text || !host || !host.edit) {
+ resetInput();
+ cancelEdit();
+ return;
+ }
+ Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => {
+ if (!sent) return;
+ resetInput();
+ cancelEdit();
+ });
+ return;
+ }
+ if (!text && !pending.length) return;
+ const payload = { text };
+ if (pending.length) payload.attachments = pending.map((p) => p.att);
+ // A root-pinned block derives its reply target fresh at send time (the
+ // root may have loaded since); the main composer sends what's staged.
+ if (cfg.fixedReply) {
+ const target = cfg.fixedReply();
+ if (target) payload.replyTo = target;
+ } else if (replyTo) payload.replyTo = replyTo;
+ // Only clear the composer once the send actually reaches the server: ctx.emit
+ // resolves false when the socket is down/reconnecting, so keep the text, staged
+ // files, and reply target intact to resend — never silently drop what was typed.
+ ctx.emit(payload).then((sent) => {
+ if (!sent) return;
+ resetInput();
+ pending = [];
+ // The caller's post-send policy runs here — arming the next render's
+ // scroll pin, re-staging an open thread view's reply — exactly where
+ // the staged-reply/thread handoff always sat (before the strip repaint
+ // and the refocus below).
+ if (cfg.onSent) cfg.onSent(payload);
+ refreshStaged();
+ composer.focus();
+ // A scripted conversation awaiting an answer in this thread is told what
+ // was sent — text AND attachments (the setup photo step takes a picture as
+ // its answer). The message itself already sent normally; it's in the
+ // transcript either way. Primary-surface machinery, like the asks it
+ // answers.
+ if (primary) deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r));
+ });
+ };
+
+ // The block's outward face. startReply/startEdit/refreshSend/tickTyping are
+ // the registry verbs (buildSkeleton publishes them in `composers`); the
+ // rest are the narrow seams the pane chrome — thread view, hotkeys, native
+ // bridge, command panel, drop targets — needs to drive this composer
+ // without owning its state.
+ const api = {
+ refreshSend,
+ startReply(m) {
+ editing = null;
+ // Replying to a reply continues its thread: target the TOP message.
+ replyTo = replyTarget(((ctx.getState() || {}).msgs) || [], m);
+ refreshBanners();
+ // Embedded: the native shell draws the reply banner and focuses its own
+ // composer (this one is hidden).
+ if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
+ else composer.focus();
+ },
+ startEdit(m) {
+ replyTo = null;
+ editing = m;
+ composer.setMarkdown(m.text, { notify: true }); // resize + reveal send
+ editBaseline = composer.getMarkdown(); // ↓-undo compares against this, not raw bytes
+ refreshBanners();
+ if (inEmbed) host.native({ editing: { text: m.text } });
+ if (!inEmbed) composer.focus();
+ },
+ // stageReply arms a reply target the caller derived itself (the thread
+ // view's root, quoted-snapshot fallback included) — startReply's
+ // resolve-and-focus stays for bubble actions; focus is the caller's call.
+ stageReply(target) {
+ editing = null;
+ replyTo = target;
+ refreshBanners();
+ if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
+ },
+ cancelReply,
+ cancelEdit,
+ stagedReply: () => replyTo,
+ editing: () => !!editing,
+ focus: () => composer.focus(),
+ submit: () => compose.requestSubmit(),
+ stageFiles,
+ uploading: () => pending.some((p) => !p.att), // an upload still in flight
+ staged: () => pending.length > 0,
+ typing: (hasText) => (hasText ? beatTyping() : stopTypingBeat()),
+ tickTyping, // render() re-arms the outbound-beat ticker each draw
+ };
+ // Four sibling nodes, not a wrapper: the .embed/.readonly rules hide the
+ // composer surfaces with `.chatapp-root > .composer` child combinators, so
+ // the block must land as direct children of the caller's root.
+ const el = document.createDocumentFragment();
+ el.append(float, replyBanner, editBanner, compose);
+ return { el, float, composer, api };
+}
+
+// ---- skeleton: feed, panels, thread view, composer (original markup) --------
+
+// buildSkeleton constructs the once-per-mount frame — the feed, the command
+// panel, the reply-thread view, and (via buildComposerBlock above) the main
+// composer with the original structure and class names: .composer-reply /
+// .composer-edit banners, the floating .composer-preview staging strip, the
+// .composer form — so style.css styles them identically to the original ones
+// (which are hidden in app-mode). Send emits the original untyped payload;
+// editing re-sends the full original payload with new text through the host
+// bridge.
+function buildSkeleton(el, ctx) {
+ const root = document.createElement("div");
+ root.className = "chatapp-root";
+
+
+ const feed = document.createElement("div");
+ feed.className = "feed chatapp-feed"; // .feed for original descendant rules; .chatapp-feed mirrors #feed's layout
+ // The wrapper bounds the reply-thread overlay to exactly the feed's box (it
+ // dims the transcript, never the composer). Feed selectors everywhere are
+ // depth-independent (.chatapp-feed querySelectors, no child combinators), so
+ // the extra level is invisible to the rest of the shell + native probes.
+ const feedWrap = document.createElement("div");
+ feedWrap.className = "chatapp-feedwrap";
+ feedWrap.appendChild(feed);
+
+ // Jump-to-latest: the classic chat affordance for getting back to the
+ // newest message without hunting for it — shown/hidden by the feed's
+ // "scroll" listener below (and re-checked after each render, since new
+ // messages can widen the gap without a scroll event).
+ const jumpBtn = document.createElement("button");
+ jumpBtn.type = "button";
+ jumpBtn.className = "feed-jump-btn hidden";
+ // aria-label carries the accessible name; title mirrors it as a mouse-hover
+ // tooltip (icon-only button — see docs/accessibility.md's icon-label rule).
+ jumpBtn.setAttribute("aria-label", "Jump to latest");
+ jumpBtn.title = "Jump to latest";
+ jumpBtn.innerHTML = icon("chevronDown");
+ jumpBtn.onclick = () => feedGlideToBottom(feed, false);
+ feedWrap.appendChild(jumpBtn);
+
+ // The composer is built by buildComposerBlock so the docked thread pane
+ // (next PR) can instantiate a second, root-pinned composer beside this one.
+ // The cfg hooks below wire the block back into pane-owned chrome — the
+ // command panel, the reply-thread view, the feed's follow behaviour — which
+ // stays out here (per-pane, not per-composer). Hook closures reference
+ // consts declared further down (panelOpen, closeThread, …): they only run
+ // on user input, long after buildSkeleton completes, so every binding is
+ // live by then (the same hoisted-handlers pattern the block itself uses).
+ const inEmbed = !!(host && host.embed);
+ const block = buildComposerBlock(ctx, host, {
+ fixedReply: null, // the main composer stages replies freely (and owns the labelled-ask surface)
+ inEmbed,
+ commands: true,
+ // Escape peels pane layers before the composer cancels its own reply/edit:
+ // an open command panel is the most recently summoned layer, then the
+ // thread view — unless an edit is in flight, which Escape must cancel
+ // first (cancelEdit re-stages the thread reply, so a second Escape then
+ // closes the view).
+ onEscape: (hasEdit) => {
+ if (panelOpen()) {
+ hidePanel();
+ return true;
+ }
+ if (threadSid && !hasEdit) {
+ closeThread();
+ return true;
+ }
+ return false;
+ },
+ // 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.
+ onReplyCancel: () => {
+ if (!threadSid) 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.
+ onEditCancelled: () => {
+ if (threadSid) stageThreadReply();
+ },
+ // The composer just grew, shrinking the flex feed. Re-pin to the newest
+ // message synchronously in the same layout — the ResizeObserver below also
+ // re-pins, but only on a later frame, which leaves one painted frame
+ // stranded a line short of the bottom (the visible "jump" on WebKit / the
+ // Mac app). Guard on feedAtBottom so typing never yanks a reader who
+ // scrolled up.
+ onFieldGrow: () => {
+ if (feedAtBottom) feed.scrollTop = feed.scrollHeight;
+ },
+ // The staging strip takes real layout space, so the feed shrinks when it
+ // appears — keep the newest messages (and any action chips) in view, not
+ // under the fold.
+ onStripChange: () => {
+ if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
+ },
+ // 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.
+ onSent: () => {
+ pinNext.add(ctx.threadId);
+ if (threadSid) stageThreadReply();
+ else block.api.cancelReply();
+ },
+ });
+ const { composer, api } = block;
+
+ root.append(feedWrap, block.el);
+ el.appendChild(root);
+
+ // Keep the feed pinned to the newest message when the composer, its staged
+ // reply/edit banner, or the preview strip grows and steals vertical space —
+ // they're in-flow siblings, so the flex feed shrinks and this observer fires.
+ // (The original re-pins the same way; render() alone can't, since a resize
+ // isn't a state change.) Track "at bottom" from real scrolls so the decision
+ // reflects the state *before* the feed shrank — reading the gap inside the
+ // callback would already show it past the threshold and wrongly bail. The
+ // `< 60` matches render()'s pin check.
+ let feedAtBottom = true;
+ const updateFeedScrollState = () => {
+ const gap = feed.scrollHeight - feed.scrollTop - feed.clientHeight;
+ feedAtBottom = gap < 60;
+ jumpBtn.classList.toggle("hidden", gap < JUMP_BUTTON_GAP);
+ };
+ feed.addEventListener("scroll", updateFeedScrollState, { passive: true });
+ // render() re-checks after every message-list update too — new messages
+ // widen the gap (or a glide-to-bottom closes it) without firing "scroll".
+ root._updateJumpBtn = updateFeedScrollState;
+ new ResizeObserver(() => {
+ if (!feedAtBottom) return;
+ // WebKit restores the pre-resize scrollTop at *layout* time — which lands
+ // after this callback AND after a single rAF — so one re-assert loses the
+ // race and strands the feed the growth-delta (~one composer line) above the
+ // bottom (Chrome doesn't do this). Re-pin across a few frames so a
+ // correction always lands after the restoration, whenever it happens. The
+ // scroll listener keeps feedAtBottom true through the restoration (its gap
+ // stays under the 60px threshold), so this bails only if the reader
+ // genuinely scrolls up mid-window.
+ let frame = 0;
+ const pin = () => {
+ if (!feedAtBottom) return; // reader scrolled away — stop fighting them
+ feed.scrollTop = feed.scrollHeight;
+ if (++frame < 4) requestAnimationFrame(pin);
+ };
+ pin();
+ }).observe(feed);
+
+ if (inEmbed) root.classList.add("embed"); // the native shell draws the composer
+ // The owning app declared its chat a read-only stream — no composer to draw.
+ if (appChatReadOnly(ctx.threadId)) root.classList.add("readonly");
+
+ // Bubble actions (Reply, Edit) resolve their composer through this registry
+ // — still exactly one entry per thread: the main composer's verbs, straight
+ // off the block's api (tickTyping rides along for render()'s beat ticker).
+ composers.set(ctx.threadId, {
+ refreshSend: api.refreshSend,
+ startReply: api.startReply,
+ startEdit: api.startEdit,
+ tickTyping: api.tickTyping,
+ });
+
+ // ---- command panel: the transient results surface a command opens ----
+ // (ctx.panel — see the command-panels section of apps.js.) In-flow, never
+ // floating: it sits above the composer stack by default, or pinned above the
+ // feed with position: "top", taking layout space either way. One per thread —
+ // opening another replaces it; its ✕, Escape, or the app's panel.close()
+ // dismisses it. Client-only chrome: nothing here is sent or folded.
+ const panelBox = document.createElement("section");
+ panelBox.className = "cmd-panel hidden";
+ panelBox.setAttribute("role", "region");
+ let panelSpec = null;
+ const panelOpen = () => !!panelSpec;
+ const renderPanelBody = (spec, body) => {
+ body.replaceChildren();
+ // The handle a panel renders against: dismiss it, redraw it, and act on
+ // the composer it sits above — insert text at the caret, or stage
+ // attachments through the same path the attach button / paste / drop use.
+ const panel = {
+ threadId: ctx.threadId,
+ close: () => hidePanel(),
+ refresh: () => {
+ if (panelSpec === spec && body.isConnected) renderPanelBody(spec, body);
+ },
+ insertText: (t) => {
+ const text = String(t == null ? "" : t);
+ if (!text) return;
+ // Embedded: the native shell owns the visible composer — forward the
+ // insertion (a shell that doesn't handle composerInsert ignores it).
+ if (inEmbed) return host.native({ composerInsert: text });
+ composer.insertAtCaret(text);
+ },
+ attachFiles: (files) => api.stageFiles([...(files || [])]),
+ };
+ try {
+ spec.render(body, panel);
+ } catch (e) {
+ // A panel must never take down the pane — contain the error in place.
+ console.error(`command panel (${(spec.sender && spec.sender.name) || "app"}) render failed:`, e);
+ body.replaceChildren();
+ const p = document.createElement("p");
+ p.textContent = "This panel hit an error and couldn't load.";
+ body.appendChild(p);
+ }
+ };
+ // Whether the panel eases open/shut: the app's call (animate: false opts
+ // out), gated on reduced-motion — and skipped in the command-host embed,
+ // where the panel is the whole page and the NATIVE shell animates the
+ // frame instead (an in-page ease inside a just-revealed frame reads as a
+ // flash, not a slide).
+ // (The side column skips the ease outright — its reveal is horizontal, and
+ // a height roll inside a full-height column reads as a glitch.)
+ const panelEases = (spec) =>
+ spec && spec.animate !== false && spec.position !== "side" && motionOK() && !document.body.classList.contains("command-host");
+ const clearPanelEase = () => {
+ panelBox.style.removeProperty("height");
+ panelBox.style.removeProperty("overflow");
+ panelBox.style.removeProperty("transition");
+ };
+ const showPanel = (spec) => {
+ const prev = panelSpec;
+ panelSpec = spec && typeof spec.render === "function" ? spec : null;
+ clearPanelEase(); // a mid-animation replace starts clean
+ // Replacing (or closing) a panel tells the outgoing one — best-effort.
+ if (prev && prev.onClose) {
+ try {
+ prev.onClose();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ panelBox.replaceChildren();
+ panelBox.classList.toggle("hidden", !panelSpec);
+ panelBox.classList.toggle("cmd-panel--top", !!(panelSpec && panelSpec.position === "top"));
+ panelBox.classList.toggle("cmd-panel--side", !!(panelSpec && panelSpec.position === "side"));
+ // The side placement reserves a right-hand gutter on the root (padding,
+ // so the column occupies real layout space and nothing renders under it).
+ root.classList.toggle("has-side-panel", !!(panelSpec && panelSpec.position === "side"));
+ if (!panelSpec) {
+ notifyCommandPanel(ctx.threadId, null);
return;
}
- // "@To-do buy milk" in a chat with apps added: the mention addresses the
- // app, the text runs its default command — an EVENT into this thread (shown
- // as an action line), not a message. Leading-@ only; a mention mid-text is
- // ordinary conversation. See runAppMention.
- if (!editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
- resetInput();
- return;
+ // Every placement is in-flow: "top" pins it above the feed (under the
+ // shared-app surface); "side" is a full-height right column (a vertical
+ // sidebar — list-shaped content wants a tall, narrow box, not a wide,
+ // short band; on narrow screens its CSS falls back to the bottom band);
+ // the default sits at the bottom of the pane, above the composer stack
+ // (before the zero-height .composer-float anchor).
+ root.insertBefore(panelBox, panelSpec.position === "top" ? feedWrap : block.float);
+ const head = document.createElement("div");
+ head.className = "cmd-panel-head";
+ const s = panelSpec.sender || {};
+ if (s.glyph) {
+ const g = document.createElement("span");
+ g.textContent = s.glyph;
+ head.appendChild(g);
}
- // Plain text in a default-command thread runs the app's default command
- // instead of sending a message nobody reads — typing "buy milk" into To-do
- // just adds it (defaultCommandFor gates this to app-threads with no bot;
- // slash text never falls through here, a "/" always means command).
- if (!editing && !replyTo && plain && plain[0] !== "/" && !pending.length) {
- const dc = defaultCmd();
- if (dc) {
- const typed = plain;
- runCommand(dc.name + " " + typed, ctx.threadId, (r) => {
- addLocalNotice(ctx, r);
- // A reply with no `short` is the command PUSHING BACK ("when should
- // I remind you?"), not a confirmation — put the typed text back so
- // it can be fixed, not retyped (matches the native .rejected path).
- if (r && !r.short && !composer.getText()) {
- composer.setText(typed);
- refreshSend();
- }
- });
- resetInput();
- return;
+ const title = document.createElement("span");
+ title.className = "cmd-panel-title";
+ title.textContent = panelSpec.title || s.name || "";
+ const x = document.createElement("button");
+ x.type = "button";
+ x.className = "cmd-panel-x";
+ x.setAttribute("aria-label", "Dismiss");
+ x.innerHTML = icon("x");
+ x.onclick = () => hidePanel();
+ // headExtra: a spec-provided slot in the title row, between the title and
+ // the dismiss — for a panel's one standing control (Memory's "Forget…"),
+ // so it sits inline with the name instead of eating body space.
+ if (typeof panelSpec.headExtra === "function") {
+ const extra = document.createElement("span");
+ extra.className = "cmd-panel-extra";
+ try {
+ panelSpec.headExtra(extra);
+ } catch (e) {
+ console.error(e);
}
+ head.append(title, extra, x);
+ } else {
+ head.append(title, x);
}
- if (editing) {
- // An empty edit just cancels; otherwise only clear once the edit actually
- // reaches the server — host.edit resolves false on a down socket, so the
- // typed changes survive to be resent after reconnecting.
- if (!text || !host || !host.edit) {
- resetInput();
- cancelEdit();
- return;
+ const body = document.createElement("div");
+ body.className = "cmd-panel-body";
+ panelBox.append(head, body);
+ renderPanelBody(panelSpec, body);
+ notifyCommandPanel(ctx.threadId, panelSpec);
+ // Ease up from nothing to the panel's natural height (see panelEases).
+ if (panelEases(panelSpec)) {
+ const h = panelBox.offsetHeight;
+ if (h) {
+ panelBox.style.overflow = "hidden";
+ panelBox.style.height = "0px";
+ void panelBox.offsetHeight; // commit the start height
+ panelBox.style.transition = "height 0.22s ease";
+ panelBox.style.height = h + "px";
+ panelBox.addEventListener("transitionend", clearPanelEase, { once: true });
+ setTimeout(clearPanelEase, 350); // backstop if transitionend never fires
}
- Promise.resolve(host.edit(ctx.threadId, editing.sid, { ...editing.raw, text })).then((sent) => {
- if (!sent) return;
- resetInput();
- cancelEdit();
- });
- return;
}
- if (!text && !pending.length) return;
- const payload = { text };
- if (pending.length) payload.attachments = pending.map((p) => p.att);
- if (replyTo) payload.replyTo = replyTo;
- // Only clear the composer once the send actually reaches the server: ctx.emit
- // resolves false when the socket is down/reconnecting, so keep the text, staged
- // files, and reply target intact to resend — never silently drop what was typed.
- ctx.emit(payload).then((sent) => {
- if (!sent) return;
- // Snap to the newest on the echo's re-render — your message should land in
- // view even if you'd scrolled up to reply. Set now (well before the server
- // round-trip); render() clears it.
- pinNext.add(ctx.threadId);
- resetInput();
- pending = [];
- // Sending from an open thread view keeps it open and re-stages the reply
- // to its root — the new reply folds in live and the next send continues
- // the same thread, like iMessage.
- if (threadSid) stageThreadReply();
- else cancelReply();
- refreshStaged();
- composer.focus();
- // A scripted conversation awaiting an answer in this thread is told what
- // was sent — text AND attachments (the setup photo step takes a picture as
- // its answer). The message itself already sent normally; it's in the
- // transcript either way.
- deliverAnswer(ctx.threadId, payload, (r) => addLocalNotice(ctx, r));
+ // A bottom panel takes its space from the feed — keep the newest in view.
+ if (feedNearBottom(feed, 160)) requestAnimationFrame(() => feedGlideToBottom(feed, true));
+ };
+ // Dismissal rolls the panel down before tearing it out, unless the app (or
+ // the OS) asked for instant. panelClosing keeps a second Escape (or the ✕)
+ // from re-arming the roll-up mid-flight; a NEW panel opening mid-animation
+ // wins — showPanel resets the inline styles, and the captured spec check
+ // keeps the stale finish() from tearing the newcomer down.
+ let panelClosing = false;
+ const hidePanel = () => {
+ if (!panelSpec || panelClosing) return;
+ if (!panelEases(panelSpec) || !panelBox.isConnected) return showPanel(null);
+ panelClosing = true;
+ const closing = panelSpec;
+ let finished = false;
+ const finish = () => {
+ if (finished) return;
+ finished = true;
+ panelClosing = false;
+ clearPanelEase();
+ if (panelSpec === closing) showPanel(null);
+ };
+ const startH = panelBox.offsetHeight;
+ if (!startH) return finish();
+ panelBox.style.overflow = "hidden";
+ panelBox.style.height = startH + "px";
+ void panelBox.offsetHeight;
+ panelBox.style.transition = "height 0.2s ease";
+ panelBox.style.height = "0px";
+ panelBox.addEventListener("transitionend", finish, { once: true });
+ setTimeout(finish, 320);
+ };
+ // Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
+ cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
+
+ // Drag-drop overlay affordance (.drop-overlay — the same class the original
+ // pane uses, so it looks identical). The overlay element exists in *both* modes;
+ // only the web drop *listeners* are web-only (see below).
+ const dropOverlay = document.createElement("div");
+ dropOverlay.className = "drop-overlay hidden";
+ dropOverlay.textContent = "Drop a photo or file to send";
+ root.appendChild(dropOverlay);
+ // The native macOS shell (Eleven) drives the overlay during a Finder drag:
+ // WebKit swallows the drag events natively (see the listener note below), so the
+ // page's own dragenter/leave never fire in the app. Selector-based so it always
+ // targets the connected root's overlay — survives thread switches / the
+ // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.)
+ window.__lchatDropOverlay = (show) => {
+ const o = document.querySelector(".chatapp-root .drop-overlay");
+ if (o) o.classList.toggle("hidden", !show);
+ };
+ // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't
+ // hand the page the dropped files on macOS, and merely *registering* a web drop
+ // handler makes WebKit claim the drop, bypassing the native path, so nothing
+ // uploads. So skip the web drop listeners in the native app (html.native); there
+ // the shell delivers files via __lchatAddFiles and shows the overlay via
+ // __lchatDropOverlay above.
+ if (!document.documentElement.classList.contains("native")) {
+ let dragDepth = 0;
+ root.addEventListener("dragenter", (e) => {
+ if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
+ dragDepth++;
+ dropOverlay.classList.remove("hidden");
+ });
+ root.addEventListener("dragleave", () => {
+ if (--dragDepth <= 0) {
+ dragDepth = 0;
+ dropOverlay.classList.add("hidden");
+ }
});
+ root.addEventListener("dragover", (e) => {
+ if (e.dataTransfer && [...e.dataTransfer.types].includes("Files")) e.preventDefault();
+ });
+ root.addEventListener("drop", (e) => {
+ dragDepth = 0;
+ dropOverlay.classList.add("hidden");
+ const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
+ if (files.length) {
+ e.preventDefault();
+ api.stageFiles(files);
+ }
+ });
+ }
+ // The native macOS shell (LChatMac) can't hand Finder file-drops to the page —
+ // a WKWebView doesn't deliver them and the web drop listeners above are skipped
+ // in native mode — so it fills the page's static #file-input and dispatches
+ // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its
+ // own input, so expose a handler that pulls the files off that static input
+ // into this thread's staging path; render() wires it to #file-input for the
+ // thread on screen. (A plain browser never reaches this — its drops land on the
+ // web listeners above.)
+ root._dropFiles = () => {
+ const el = document.getElementById("file-input");
+ if (!el) return;
+ const files = [...(el.files || [])];
+ el.value = "";
+ if (files.length) api.stageFiles(files);
};
// Scroll-back pagination: nearing the top folds the previous history page
@@ -3510,20 +3681,19 @@ function buildSkeleton(el, ctx) {
let threadPaging = false; // paging history backward looking for the root
// Stage the composer's reply on the thread's root — the whole point of the
- // view's composer contract ("Replying to <root author>"). Mirrors startReply
- // (a reply displaces an in-flight edit; native shells mirror the banner) but
- // skips the focus on touch, where popping the keyboard would cover the
- // thread the reader just opened.
+ // view's composer contract ("Replying to <root author>"). api.stageReply
+ // mirrors startReply (a reply displaces an in-flight edit; native shells
+ // mirror the banner); the focus is skipped on touch, where popping the
+ // keyboard would cover the thread the reader just opened.
const stageThreadReply = () => {
- editing = null;
const msgs = ((ctx.getState() || {}).msgs) || [];
const rootMsg = msgs.find((m) => m.sid === threadSid);
- replyTo = rootMsg
- ? replyTarget(msgs, rootMsg)
- : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" };
- refreshBanners();
- if (inEmbed) host.native({ replying: { name: replyTo.name || "", snippet: replyTo.snippet || "" } });
- else if (!coarse) composer.focus();
+ api.stageReply(
+ rootMsg
+ ? replyTarget(msgs, rootMsg)
+ : { id: threadSid, name: (threadFallback && threadFallback.name) || "", snippet: (threadFallback && threadFallback.snippet) || "" }
+ );
+ if (!inEmbed && !coarse) composer.focus();
};
const closeThread = () => {
@@ -3536,8 +3706,9 @@ function buildSkeleton(el, ctx) {
threadEl = null;
}
// Leaving the thread drops its staged reply — never an unrelated one staged
- // since (an edit clears replyTo itself, so that survives untouched).
- if (sid && replyTo && replyTo.id === sid) cancelReply();
+ // since (an edit clears the staged reply itself, so that survives untouched).
+ const staged = api.stagedReply();
+ if (sid && staged && staged.id === sid) api.cancelReply();
};
const buildThread = () => {
@@ -3668,7 +3839,8 @@ function buildSkeleton(el, ctx) {
refreshThread();
// The root may have just loaded — refresh the banner to its live snapshot,
// but only while the staged reply is still this thread's (not an edit).
- if (!editing && replyTo && replyTo.id === sid) stageThreadReply();
+ const staged = api.stagedReply();
+ if (!api.editing() && staged && staged.id === sid) stageThreadReply();
};
const openThread = (rootSid, ref) => {
@@ -3730,19 +3902,19 @@ function buildSkeleton(el, ctx) {
// 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 && !editing && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ if (e.key === "Escape" && threadSid && !api.editing() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
e.preventDefault();
closeThread();
return;
}
- if (e.key === "Escape" && !composer.hasFocus() && (replyTo || editing) && !document.querySelector(".emoji-picker")) {
+ if (e.key === "Escape" && !composer.hasFocus() && (api.stagedReply() || api.editing()) && !document.querySelector(".emoji-picker")) {
e.preventDefault();
- const wasEditing = !!editing;
- cancelReply();
- cancelEdit();
+ const wasEditing = api.editing();
+ api.cancelReply();
+ api.cancelEdit();
if (wasEditing) {
composer.setText("");
- refreshSend();
+ api.refreshSend();
}
}
};
@@ -3762,28 +3934,27 @@ function buildSkeleton(el, ctx) {
if (inEmbed && host.registerNative) {
root._native = {
sendText(text) {
- if (pending.some((p) => !p.att)) return false; // still uploading
+ if (api.uploading()) return false; // still uploading
composer.setText(text || "");
- if (composer.isEmpty() && !pending.length) return false;
- compose.requestSubmit();
- stopTypingBeat();
+ if (composer.isEmpty() && !api.staged()) return false;
+ api.submit();
+ api.typing(false);
return true;
},
typing(hasText) {
- if (hasText) beatTyping();
- else stopTypingBeat();
+ api.typing(hasText);
},
stage(file) {
- stageFiles([file]);
+ api.stageFiles([file]);
},
cancelReply() {
// The native banner's ✕ means the same as the web one: leaving an open
// thread view, not just dropping its staged reply.
if (threadSid) closeThread();
- else cancelReply();
+ else api.cancelReply();
},
cancelEdit() {
- cancelEdit();
+ api.cancelEdit();
},
didAttach(force) {
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;