eleven / ocho Public

web: the composer facade — chat.js stops touching the textarea directly (#531) (#885)

Behavior-identical refactor ahead of the WYSIWYG composer: a new
composer.js facade owns the input element (today: the same textarea,
auto-grow and all), and chat.js speaks only its contract — getText/
getCaret/replaceRange keep the typeahead's frozen plain-string+caret
shape; programmatic writes never fire callbacks unless asked (the
native sendText buffer must not emit typing beats).

shell.js's hasUnsentDraft (the auto-update reload gate) moves onto the
facade registry — the old '.composer textarea' query would silently
miss a contenteditable composer and reload over drafts — and the four
composer-focus selectors switch to the backend-neutral .composer-input.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 73c6cbe034fbfddab849eb6a5dc93ae017f02f2d parent 9c860e2
3 files changed, +235 −93
  • web/static/apps/chat.js +81 −85
  • web/static/composer.js +142 −0
  • web/static/shell.js +12 −8
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 352dee8..562c9ac 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -25,6 +25,7 @@
import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { parseMessage, renderTree } from "../markdown.js";
+import { createComposer } from "../composer.js";
import { renderCard, CARD_CSS } from "../cards.js";
import { emojify } from "../emoji.js";
import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js";
@@ -2646,7 +2647,6 @@ function buildSkeleton(el, ctx) {
fieldLabel.className = "composer-label";
fieldLabel.setAttribute("aria-hidden", "true");
fieldLabel.hidden = true;
- const input = document.createElement("textarea");
// 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
// routing is only honest if the composer advertises it before the first send.
@@ -2655,28 +2655,48 @@ function buildSkeleton(el, ctx) {
const dc = defaultCmd();
return (dc && dc.app.composerPlaceholder) || "Message";
};
+ // The message input itself lives behind the composer facade (composer.js) —
+ // chat.js never touches an element's .value directly, so the rich backend
+ // 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 composer = createComposer({
+ placeholder: composerHints.get(ctx.threadId) || basePlaceholder(),
+ onInput: () => onComposerInput(),
+ onKeydown: (e) => onComposerKeydown(e),
+ // Caret moves (click / arrows) can enter or leave a token too — re-check.
+ onKeyup: (e) => {
+ if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
+ },
+ onClick: () => updateTypeahead(),
+ // The original keyboard-inset rule (.composer.editing on coarse pointers)
+ // keys off focus — mirror it so the composer sits on the keyboard.
+ onFocus: () => compose.classList.add("editing"),
+ onBlur: () => {
+ compose.classList.remove("editing");
+ setTimeout(closeTypeahead, 100);
+ },
+ onPaste: (e) => onComposerPaste(e),
+ });
const applyFieldLabel = () => {
const label = composerLabels.get(ctx.threadId) || "";
fieldLabel.textContent = label;
fieldLabel.hidden = !label;
- input.setAttribute("aria-label", label ? label.replace(/[::]\s*$/, "") : "Message");
+ composer.setAriaLabel(label ? label.replace(/[::]\s*$/, "") : "Message");
if (!label) {
- input.style.paddingLeft = "";
+ composer.setIndent(0);
return;
}
// Indent the text past the pill — measured after layout so the width is real.
requestAnimationFrame(() => {
- if (!fieldLabel.hidden) input.style.paddingLeft = `${fieldLabel.offsetWidth + 12 + 8}px`;
+ if (!fieldLabel.hidden) composer.setIndent(fieldLabel.offsetWidth + 12 + 8);
});
};
- input.placeholder = composerHints.get(ctx.threadId) || basePlaceholder();
refreshComposerHint.set(ctx.threadId, () => {
const attach = pending.length ? composerAttachHints.get(ctx.threadId) : "";
- input.placeholder = attach || composerHints.get(ctx.threadId) || basePlaceholder();
+ composer.setPlaceholder(attach || composerHints.get(ctx.threadId) || basePlaceholder());
applyFieldLabel();
});
- input.rows = 1;
- input.autocomplete = "off";
const send = document.createElement("button");
send.type = "submit";
send.className = "send-btn";
@@ -2684,7 +2704,7 @@ function buildSkeleton(el, ctx) {
send.hidden = true;
send.innerHTML =
'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="6 11 12 5 18 11"></polyline></svg>';
- field.append(fieldLabel, input, send);
+ field.append(fieldLabel, composer.el, send);
compose.append(attach, fileInput, field);
// A default-command thread takes text only — with no one to read a file, an
// attachment is a dead end, so the affordance goes (stageFiles gates paste and
@@ -2878,7 +2898,7 @@ function buildSkeleton(el, ctx) {
refreshSend();
// Removing a chip can resolve a queued send either way: nothing left to
// send cancels it; removing the last still-uploading chip releases it.
- if (sendQueued && !input.value.trim() && !pending.length) setSendQueued(false);
+ if (sendQueued && composer.isEmpty() && !pending.length) setSendQueued(false);
else fireQueuedSend();
};
chip.appendChild(x);
@@ -2889,7 +2909,7 @@ function buildSkeleton(el, ctx) {
// upload is in flight — the original rules exactly. It also greys while the
// socket is down (a send would be dropped), mirroring the original composer.
const refreshSend = () => {
- send.hidden = input.value.trim() === "" && pending.length === 0;
+ send.hidden = composer.isEmpty() && pending.length === 0;
send.disabled = pending.some((p) => !p.att) || !connected;
};
// Enter during an upload queues the send instead of dropping it: the button
@@ -2918,16 +2938,15 @@ function buildSkeleton(el, ctx) {
// 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 input.focus();
+ else composer.focus();
},
startEdit(m) {
replyTo = null;
editing = m;
- input.value = m.text;
- input.dispatchEvent(new Event("input")); // resize + reveal send
+ composer.setText(m.text, { notify: true }); // resize + reveal send
refreshBanners();
if (inEmbed) host.native({ editing: { text: m.text } });
- if (!inEmbed) input.focus();
+ if (!inEmbed) composer.focus();
},
});
@@ -3030,12 +3049,7 @@ function buildSkeleton(el, ctx) {
// 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 });
- const start = input.selectionStart == null ? input.value.length : input.selectionStart;
- const end = input.selectionEnd == null ? start : input.selectionEnd;
- input.value = input.value.slice(0, start) + text + input.value.slice(end);
- input.selectionStart = input.selectionEnd = start + text.length;
- input.dispatchEvent(new Event("input", { bubbles: true }));
- input.focus();
+ composer.insertAtCaret(text);
},
attachFiles: (files) => stageFiles([...(files || [])]),
};
@@ -3178,7 +3192,7 @@ function buildSkeleton(el, ctx) {
};
// Programmatic closes (ctx.closePanel) take the same eased path as ✕/Escape.
cmdPanels.set(ctx.threadId, (spec) => (spec ? showPanel(spec) : hidePanel()));
- input.addEventListener("paste", (e) => {
+ function onComposerPaste(e) {
const files = [...((e.clipboardData && e.clipboardData.files) || [])];
if (files.length) {
e.preventDefault();
@@ -3190,24 +3204,19 @@ function buildSkeleton(el, ctx) {
if (!files.length) {
const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
if (textItem) {
- const start = input.selectionStart;
- const beforeCursor = input.value.substring(0, start);
+ 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");
- const end = input.selectionEnd;
- const before = input.value.substring(0, start);
- const after = input.value.substring(end);
- input.value = before + blockquoted + after;
- input.selectionStart = input.selectionEnd = start + blockquoted.length;
- input.dispatchEvent(new Event("input", { bubbles: true }));
+ 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).
@@ -3285,15 +3294,12 @@ function buildSkeleton(el, ctx) {
const acceptTypeahead = () => {
if (!typeahead) return;
const it = typeahead.items[typeahead.index];
- const caret = input.selectionStart;
- const before = input.value.slice(0, typeahead.start);
- const after = input.value.slice(caret);
- input.value = before + it.insert + after;
- const pos = before.length + it.insert.length;
- input.setSelectionRange(pos, pos);
+ const caret = composer.getCaret();
+ const start = typeahead.start;
closeTypeahead();
- input.dispatchEvent(new Event("input")); // resize + send-visibility + re-check token
- input.focus();
+ // 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;
@@ -3355,7 +3361,7 @@ function buildSkeleton(el, ctx) {
taBox.children[typeahead.index]?.scrollIntoView({ block: "nearest" });
};
const updateTypeahead = () => {
- const tok = composerToken(input.value, input.selectionStart);
+ const tok = composerToken(composer.getText(), composer.getCaret());
if (!tok || !host || !host.typeaheadItems) return closeTypeahead();
const items = host.typeaheadItems(tok);
if (!items.length) return closeTypeahead();
@@ -3403,34 +3409,25 @@ function buildSkeleton(el, ctx) {
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
- input.addEventListener("input", () => {
- input.style.height = "auto";
- input.style.height = Math.min(input.scrollHeight, 140) + "px";
- // 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.
+ 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;
if (sendQueued) setSendQueued(false); // still composing — don't auto-send under them
refreshSend();
updateTypeahead();
// A "/command" in progress is private — never announce typing for it (and
// clear any beat already sent if the text just became a command).
- if (input.value && input.value[0] !== "/") beatTyping();
+ const v = composer.getText();
+ if (v && v[0] !== "/") beatTyping();
else if (lastBeat) stopTypingBeat();
- });
- // The original keyboard-inset rule (.composer.editing on coarse pointers)
- // keys off focus — mirror it so the composer sits on the keyboard while typing.
- input.addEventListener("focus", () => compose.classList.add("editing"));
- input.addEventListener("blur", () => compose.classList.remove("editing"));
- // Caret moves (click / arrows) can enter or leave a token too — re-check.
- input.addEventListener("keyup", (e) => {
- if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
- });
- input.addEventListener("click", updateTypeahead);
- input.addEventListener("blur", () => setTimeout(closeTypeahead, 100));
- input.onkeydown = (e) => {
+ }
+ function onComposerKeydown(e) {
// The typeahead grabs the nav keys first when it's open (original order).
if (typeahead) {
if (e.key === "ArrowDown") return e.preventDefault(), moveTypeahead(1);
@@ -3466,12 +3463,12 @@ function buildSkeleton(el, ctx) {
cancelReply();
cancelEdit();
if (wasEditing) {
- input.value = "";
+ composer.setText("");
refreshSend();
}
}
// ↑ on an empty composer edits your latest still-editable message.
- if (e.key === "ArrowUp" && !input.value && !editing) {
+ if (e.key === "ArrowUp" && !composer.getText() && !editing) {
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)
@@ -3484,15 +3481,15 @@ function buildSkeleton(el, ctx) {
// ↓ undoes an accidental ↑-to-edit: only while unchanged and with no line
// below the cursor to navigate to (the original rule).
if (e.key === "ArrowDown" && editing) {
- const afterCursor = input.value.slice(input.selectionEnd);
- if (input.value === editing.text && !afterCursor.includes("\n")) {
+ const afterCursor = composer.getText().slice(composer.getSelectionEnd());
+ if (composer.getText() === editing.text && !afterCursor.includes("\n")) {
e.preventDefault();
- input.value = "";
+ composer.setText("");
cancelEdit();
refreshSend();
}
}
- };
+ }
compose.onsubmit = (ev) => {
ev.preventDefault();
@@ -3500,10 +3497,9 @@ function buildSkeleton(el, ctx) {
setSendQueued(true); // still uploading — send as soon as it lands
return;
}
- const text = emojify(input.value.trim()); // :tada: → 🎉, like the original composer
+ const text = emojify(composer.getText().trim()); // :tada: → 🎉, like the original composer
const resetInput = () => {
- input.value = "";
- input.style.height = "auto";
+ 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
@@ -3552,8 +3548,8 @@ function buildSkeleton(el, ctx) {
// 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 && !input.value) {
- input.value = typed;
+ if (r && !r.short && !composer.getText()) {
+ composer.setText(typed);
refreshSend();
}
});
@@ -3598,7 +3594,7 @@ function buildSkeleton(el, ctx) {
if (threadSid) stageThreadReply();
else cancelReply();
refreshStaged();
- input.focus();
+ 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
@@ -3651,7 +3647,7 @@ function buildSkeleton(el, ctx) {
: { 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) input.focus();
+ else if (!coarse) composer.focus();
};
const closeThread = () => {
@@ -3835,48 +3831,48 @@ function buildSkeleton(el, ctx) {
// re-attaches this parked root without rebuilding the skeleton.
root._hotkeys = (e) => {
if (!root.isConnected) return;
- if ((e.metaKey || e.ctrlKey) && e.key === "t" && !input.value) {
+ if ((e.metaKey || e.ctrlKey) && e.key === "t" && !composer.getText()) {
e.preventDefault();
const m = latest(true);
if (m) openMessagePicker(m, ctx, feed.querySelector(`.msg[data-id="${m.sid}"]`) || feed);
}
- if ((e.metaKey || e.ctrlKey) && e.key === "r" && !input.value) {
+ if ((e.metaKey || e.ctrlKey) && e.key === "r" && !composer.getText()) {
e.preventDefault();
const m = latest(false);
if (m) composers.get(ctx.threadId).startReply(m);
}
// Escape dismisses an open command panel when the composer isn't focused
- // (the focused case is in input.onkeydown), with the same layer checks as
+ // (the focused case is in onComposerKeydown), with the same layer checks as
// the thread-view close below — one Escape peels one layer, never two.
- if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ if (e.key === "Escape" && panelOpen() && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
e.preventDefault();
hidePanel();
return;
}
// Escape closes an open thread view when the composer isn't focused (the
- // focused case is handled in input.onkeydown). Skipped when a layer that
+ // 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 && !editing && !e.defaultPrevented && document.activeElement !== input && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
+ if (e.key === "Escape" && threadSid && !editing && !e.defaultPrevented && !composer.hasFocus() && !document.querySelector(".emoji-picker, .msg-sheet, .lightbox")) {
e.preventDefault();
closeThread();
return;
}
- if (e.key === "Escape" && document.activeElement !== input && (replyTo || editing) && !document.querySelector(".emoji-picker")) {
+ if (e.key === "Escape" && !composer.hasFocus() && (replyTo || editing) && !document.querySelector(".emoji-picker")) {
e.preventDefault();
const wasEditing = !!editing;
cancelReply();
cancelEdit();
if (wasEditing) {
- input.value = "";
+ composer.setText("");
refreshSend();
}
}
};
// Desktop opens ready to type, like the original feed.
- if (!coarse && !inEmbed) input.focus();
+ if (!coarse && !inEmbed) composer.focus();
// Embedded: the native composer drives THIS composer through the shell's
// window.lchatNative bridge (see installNativeBridge in chat.js) — text,
@@ -3891,8 +3887,8 @@ function buildSkeleton(el, ctx) {
root._native = {
sendText(text) {
if (pending.some((p) => !p.att)) return false; // still uploading
- input.value = text || "";
- if (!input.value.trim() && !pending.length) return false;
+ composer.setText(text || "");
+ if (composer.isEmpty() && !pending.length) return false;
compose.requestSubmit();
stopTypingBeat();
return true;
diff --git a/web/static/composer.js b/web/static/composer.js
new file mode 100644
index 0000000..64e9b78
--- /dev/null
+++ b/web/static/composer.js
@@ -0,0 +1,142 @@
+// 🤖 The composer facade (#531, the WYSIWYG groundwork): chat.js talks to its
+// message input ONLY through the handle this module returns, never to a DOM
+// element's .value directly. Today there is one backend — "plain", the same
+// <textarea> as always, byte-identical behaviour — and the rich (ProseMirror)
+// backend slots in behind the same contract without chat.js changing again.
+// The plain backend is permanent, not transitional: embed mode (the native
+// shells' hidden composer) and the "classic composer" escape hatch keep it
+// exercised forever.
+//
+// Contract notes that both backends must honour:
+// - getText()/getCaret()/replaceRange() speak plain string + caret offset —
+// the typeahead contract (ui.js composerToken and its Swift twin) is frozen.
+// - Programmatic writes never fire callbacks unless asked ({ notify: true }):
+// the native bridge sets text right before submitting, and a surprise
+// onInput would emit a spurious typing beat.
+// - getMarkdown()/setMarkdown() are the wire-format pair; for plain they're
+// just the value (what you type is what sends, exactly as today).
+
+// Live composers, weakly held: panes park in shell.js's appPaneCache (the
+// composer survives, detached) and are sometimes dropped without a teardown
+// hook, so strong references would pin dead composers forever. Callers filter
+// by connectedness — matching the old ".composer textarea" DOM query, which
+// only ever saw the mounted pane.
+const live = new Set();
+
+// Does any MOUNTED composer hold unsent text? (shell.js gates the auto-update
+// reload on this — same semantics as the old document-wide selector: parked
+// panes didn't block then and don't now.)
+export function anyComposerHasText() {
+ for (const ref of live) {
+ const c = ref.deref();
+ if (!c) {
+ live.delete(ref);
+ continue;
+ }
+ if (c.el.isConnected && !c.isEmpty()) return true;
+ }
+ return false;
+}
+
+export function createComposer(opts = {}) {
+ const handle = createPlainComposer(opts);
+ live.add(new WeakRef(handle));
+ return handle;
+}
+
+// The plain backend — today's <textarea>, behaviour preserved to the letter
+// (auto-grow to 140px, input/keydown wiring, splice-based typeahead inserts).
+function createPlainComposer(opts) {
+ const el = document.createElement("textarea");
+ el.className = "composer-input";
+ el.rows = 1;
+ el.autocomplete = "off";
+ if (opts.placeholder) el.placeholder = opts.placeholder;
+
+ const notify = () => el.dispatchEvent(new Event("input", { bubbles: true }));
+
+ el.addEventListener("input", () => {
+ // Auto-grow first, so the caller's onInput observes the post-grow layout
+ // (its feed re-pin reads scrollHeight synchronously).
+ el.style.height = "auto";
+ el.style.height = Math.min(el.scrollHeight, 140) + "px";
+ if (opts.onInput) opts.onInput();
+ });
+ if (opts.onKeydown) el.onkeydown = opts.onKeydown;
+ if (opts.onKeyup) el.addEventListener("keyup", opts.onKeyup);
+ if (opts.onClick) el.addEventListener("click", opts.onClick);
+ el.addEventListener("focus", () => opts.onFocus && opts.onFocus());
+ el.addEventListener("blur", () => opts.onBlur && opts.onBlur());
+ if (opts.onPaste) el.addEventListener("paste", opts.onPaste);
+
+ return {
+ kind: "plain",
+ el,
+ focus() {
+ el.focus();
+ },
+ hasFocus() {
+ return document.activeElement === el;
+ },
+ isEmpty() {
+ return el.value.trim() === "";
+ },
+ getText() {
+ return el.value;
+ },
+ getCaret() {
+ return el.selectionStart == null ? el.value.length : el.selectionStart;
+ },
+ getSelectionEnd() {
+ return el.selectionEnd == null ? this.getCaret() : el.selectionEnd;
+ },
+ setText(v, o = {}) {
+ el.value = v || "";
+ if (o.notify) notify();
+ },
+ // Typeahead splice: replace [start, end) in getText() coordinates, land
+ // the caret after the insert, notify (resize + send-visibility + token
+ // re-check) and keep focus — the original acceptTypeahead, verbatim.
+ replaceRange(start, end, insert) {
+ const before = el.value.slice(0, start);
+ el.value = before + insert + el.value.slice(end);
+ const pos = before.length + insert.length;
+ el.setSelectionRange(pos, pos);
+ notify();
+ el.focus();
+ },
+ // Panel insertText: at the caret (or replacing a selection), caret after.
+ insertAtCaret(text) {
+ const start = el.selectionStart == null ? el.value.length : el.selectionStart;
+ const end = el.selectionEnd == null ? start : el.selectionEnd;
+ el.value = el.value.slice(0, start) + text + el.value.slice(end);
+ el.selectionStart = el.selectionEnd = start + text.length;
+ notify();
+ el.focus();
+ },
+ getMarkdown() {
+ return el.value;
+ },
+ setMarkdown(v, o = {}) {
+ this.setText(v, o);
+ },
+ clear() {
+ el.value = "";
+ el.style.height = "auto";
+ },
+ setPlaceholder(s) {
+ el.placeholder = s || "";
+ },
+ setAriaLabel(s) {
+ el.setAttribute("aria-label", s || "");
+ },
+ // The inset field label's indent (applyFieldLabel measures the pill).
+ setIndent(px) {
+ el.style.paddingLeft = px ? `${px}px` : "";
+ },
+ inCodeBlock() {
+ return false;
+ },
+ setFormatAsYouType() {},
+ };
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 510f2ae..4927f8e 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -175,6 +175,7 @@ import {
getBackfillMark as storeBackfillMark,
runBackfill as storeRunBackfill,
} from "/static/store.js";
+import { anyComposerHasText } from "/static/composer.js";
const SESSION_KEY = "lchat_session";
const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it
@@ -2741,10 +2742,13 @@ function isAdmin() {
// an update never eats someone's draft. Covers every mounted chat-app composer
// (Split view shows two) plus the legacy #text box.
function hasUnsentDraft() {
- for (const t of document.querySelectorAll(".composer textarea, #text")) {
- if (t.value && t.value.trim()) return true;
- }
- return false;
+ // The chat composer(s) live behind the composer.js facade — Split view can
+ // mount two, and the rich backend isn't a textarea. Same semantics as the
+ // old DOM query: only MOUNTED composers block (parked panes never did).
+ if (anyComposerHasText()) return true;
+ // The retired classic feed's #text input, until its deletion.
+ const t = document.getElementById("text");
+ return !!(t && t.value && t.value.trim());
}
// On returning to the app, ask the server for the current build hash; if it shipped
@@ -2829,7 +2833,7 @@ function clearNotifications() {
// — it runs the full web UI, not the embed-only window.lchatNative bridge.
function focusChatComposer() {
if (isCoarse) return;
- const ta = document.querySelector(".chatapp-root .composer textarea");
+ const ta = document.querySelector(".chatapp-root .composer .composer-input");
if (!ta || ta === document.activeElement) return;
// Don't yank the caret out of another field the user left mid-edit (search,
// an inline rename, …) — only auto-focus when nothing editable holds focus.
@@ -5478,7 +5482,7 @@ async function jumpToMessage(tid, sid) {
// an offscreen control). The bubble itself takes it; a deleted/never-found
// target falls back to the composer.
const target = feed.querySelector(`.msg[data-id="${sid}"]`);
- const focusEl = target || document.querySelector(".chatapp-root .composer textarea");
+ const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input");
if (focusEl) {
if (target) target.tabIndex = -1;
focusEl.focus({ preventScroll: true });
@@ -6465,7 +6469,7 @@ async function maybeStartWelcome() {
await openThreadById(dmId);
// They tapped "Say hi" — put the cursor where the hi goes.
requestAnimationFrame(() => {
- const ta = document.querySelector(".chatapp-root .composer textarea");
+ const ta = document.querySelector(".chatapp-root .composer .composer-input");
if (ta) ta.focus();
});
};
@@ -9376,7 +9380,7 @@ function updateRenameBanner(t) {
// the composer so keyboard users aren't dropped onto <body>.
const choose = async (keepMine) => {
await resolveRename(other, keepMine);
- const box = view.querySelector(".chatapp-root .composer textarea") || document.getElementById("text");
+ const box = view.querySelector(".chatapp-root .composer .composer-input") || document.getElementById("text");
if (box) box.focus();
};
for (const b of renameChoices(notice, choose)) bar.appendChild(b);