eleven / messenger Public

web: the WYSIWYG composer — ProseMirror backend, format-as-you-type (#531) (#888)

The rich backend behind the PR #885 facade: composer-pm.js mounts a
ProseMirror EditorView wearing the textarea's exact skin (auto-grow is
pure CSS — no JS measuring to go stale). Format-as-you-type input rules
(**bold**, *i*, `code`, ~s~, lists, quotes, fences) with Backspace-undo;
⌘B/⌘I/⌘E/⌘⇧X/⌘⇧7/8/9 shortcuts; desktop Enter sends everywhere except
inside a code fence; Shift+Enter (and touch Enter) stay structural —
the keyboard never splits paragraphs, so wire bytes never drift. Huge
pastes skip coercion past 100KB and refuse past 1MB.

chat.js: primed dynamic import (never in native webviews; plain
fallback if it fails), commands read plaintext while the wire carries
serialized markdown, edit loads parse markdown and ↓-undo compares a
captured baseline. Theme sheet gains 'Format as you type' (default on,
live reconfigure) and 'Classic plain-text composer' (the escape hatch).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9548c6a86e5441cc4cf047558fc40c9dfdbc83dc parent 69f06f8
6 files changed, +457 −20
  • changelog/2026-07-14-rich-composer.md +7 −0
  • web/static/apps/chat.js +45 −15
  • web/static/composer-pm.js +271 −0
  • web/static/composer.js +38 −1
  • web/static/shell.js +33 −1
  • web/static/style.css +63 −3
diff --git a/changelog/2026-07-14-rich-composer.md b/changelog/2026-07-14-rich-composer.md
new file mode 100644
index 0000000..32c1c19
--- /dev/null
+++ b/changelog/2026-07-14-rich-composer.md
@@ -0,0 +1,7 @@
+🤖 The message box now formats as you type. Type `**bold**` and it turns bold
+the moment you close the stars — same for *italics*, `code`, ~strikethrough~,
+"- " lists, "> " quotes and ``` code blocks — or use the shortcuts you already
+know: ⌘B, ⌘I, and friends. Backspace right after undoes the formatting, and
+what you send is exactly what everyone sees, on every device. Prefer typing
+the markers and seeing them stay put? Turn off "Format as you type" in the
+Theme sheet — or switch back to the classic plain-text box entirely.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 562c9ac..06e2b0f 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -25,7 +25,18 @@
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 { createComposer, adoptComposer, classicComposerPref, formatAsYouTypePref } from "../composer.js";
+
+// The rich (ProseMirror) composer backend loads via a primed dynamic import —
+// fetching starts at module load, mounts long after — and never in the native
+// shells' webviews (they draw their own composer; the hidden one stays the
+// cheap textarea). If the import fails, the factory quietly falls back to the
+// plain backend: chat always works.
+let pmModule = null;
+// (typeof guard: the event-vector tests import this module under Node.)
+if (typeof document !== "undefined" && !document.documentElement.classList.contains("native")) {
+ import("../composer-pm.js").then((m) => (pmModule = m)).catch(() => null);
+}
import { renderCard, CARD_CSS } from "../cards.js";
import { emojify } from "../emoji.js";
import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js";
@@ -2660,8 +2671,10 @@ 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 composer = createComposer({
+ const inEmbed = !!(host && host.embed);
+ const composerOpts = {
placeholder: composerHints.get(ctx.threadId) || basePlaceholder(),
+ formatAsYouType: formatAsYouTypePref(),
onInput: () => onComposerInput(),
onKeydown: (e) => onComposerKeydown(e),
// Caret moves (click / arrows) can enter or leave a token too — re-check.
@@ -2677,7 +2690,15 @@ function buildSkeleton(el, ctx) {
setTimeout(closeTypeahead, 100);
},
onPaste: (e) => onComposerPaste(e),
- });
+ // Rich-backend file routes (ProseMirror owns paste/drop on its element).
+ onPasteFiles: (files) => stageFiles(files),
+ onDropFiles: (files) => stageFiles(files),
+ onOversizePaste: () => stageError("That's too long for a message — attach it as a file instead."),
+ };
+ const composer =
+ pmModule && !inEmbed && !appChatReadOnly(ctx.threadId) && !classicComposerPref()
+ ? adoptComposer(pmModule.createRichComposer(composerOpts))
+ : createComposer(composerOpts);
const applyFieldLabel = () => {
const label = composerLabels.get(ctx.threadId) || "";
fieldLabel.textContent = label;
@@ -2753,6 +2774,7 @@ function buildSkeleton(el, ctx) {
// ---- composer state (per mount; 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
@@ -2804,7 +2826,6 @@ function buildSkeleton(el, ctx) {
replyBanner.classList.remove("hidden");
}
};
- const inEmbed = !!(host && host.embed);
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");
@@ -2943,7 +2964,8 @@ function buildSkeleton(el, ctx) {
startEdit(m) {
replyTo = null;
editing = m;
- composer.setText(m.text, { notify: true }); // resize + reveal send
+ 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();
@@ -3201,7 +3223,7 @@ function buildSkeleton(el, ctx) {
// 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) {
+ if (!files.length && composer.kind === "plain") {
const textItem = [...(e.clipboardData?.items || [])].find((i) => i.type === "text/plain");
if (textItem) {
const start = composer.getCaret();
@@ -3435,8 +3457,12 @@ function buildSkeleton(el, ctx) {
if (e.key === "Enter" || e.key === "Tab") return e.preventDefault(), acceptTypeahead();
if (e.key === "Escape") return e.preventDefault(), closeTypeahead();
}
- // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines.
- if (shouldSend(e, coarse)) {
+ // Desktop Enter sends (Shift+Enter newlines); touch Enter newlines. A
+ // code fence is the one context that explicitly means "multi-line
+ // content", so Enter newlines there too. The isComposing guard stops a
+ // Japanese/Korean composition-confirm from sending early (a pre-existing
+ // textarea quirk, fixed while we're here).
+ if (shouldSend(e, coarse) && !composer.inCodeBlock() && !e.isComposing) {
e.preventDefault();
compose.requestSubmit();
return;
@@ -3482,7 +3508,7 @@ function buildSkeleton(el, ctx) {
// below the cursor to navigate to (the original rule).
if (e.key === "ArrowDown" && editing) {
const afterCursor = composer.getText().slice(composer.getSelectionEnd());
- if (composer.getText() === editing.text && !afterCursor.includes("\n")) {
+ if (composer.getMarkdown() === editBaseline && !afterCursor.includes("\n")) {
e.preventDefault();
composer.setText("");
cancelEdit();
@@ -3497,7 +3523,11 @@ function buildSkeleton(el, ctx) {
setSendQueued(true); // still uploading — send as soon as it lands
return;
}
- const text = emojify(composer.getText().trim()); // :tada: → 🎉, like the original composer
+ // Two readings of the composer: commands parse the PLAIN text (a command
+ // is not prose), the wire carries the serialized markdown. On the plain
+ // backend the two are identical bytes, exactly as before.
+ const plain = emojify(composer.getText().trim());
+ const text = emojify(composer.getMarkdown().trim()); // :tada: → 🎉, like the original composer
const resetInput = () => {
composer.clear();
refreshSend();
@@ -3508,7 +3538,7 @@ function buildSkeleton(el, ctx) {
// line ("Alice waves"), not a bubble — instead of answering privately, so
// it's handled here rather than through runCommand (see parseMeCommand).
if (!editing) {
- const action = parseMeCommand(text);
+ const action = parseMeCommand(plain);
if (action !== null) {
if (!action) return resetInput(); // bare "/me" — nothing to say
ctx.emit({ text: `${ctx.meName || "Someone"} ${action}`, action: true }).then((sent) => {
@@ -3523,7 +3553,7 @@ function buildSkeleton(el, ctx) {
// 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(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
+ if (!editing && runCommand(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
resetInput();
return;
}
@@ -3531,7 +3561,7 @@ function buildSkeleton(el, ctx) {
// 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 && text && text[0] === "@" && runAppMention(text, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
+ if (!editing && !replyTo && !pending.length && plain && plain[0] === "@" && runAppMention(plain, ctx.threadId, (r) => addLocalNotice(ctx, r))) {
resetInput();
return;
}
@@ -3539,10 +3569,10 @@ function buildSkeleton(el, ctx) {
// 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 && text && text[0] !== "/" && !pending.length) {
+ if (!editing && !replyTo && plain && plain[0] !== "/" && !pending.length) {
const dc = defaultCmd();
if (dc) {
- const typed = text;
+ 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
diff --git a/web/static/composer-pm.js b/web/static/composer-pm.js
new file mode 100644
index 0000000..f049576
--- /dev/null
+++ b/web/static/composer-pm.js
@@ -0,0 +1,271 @@
+// 🤖 The rich composer backend (#531): a ProseMirror EditorView behind the
+// same facade contract as composer.js's plain textarea. chat.js cannot tell
+// the two apart — the handle speaks plain-string+caret for the typeahead,
+// markdown for the wire (composer-md.js owns both mappings). This module is
+// loaded via a primed dynamic import (never in embed mode), so the native
+// shells' webviews and the classic-composer escape hatch never pay for it.
+//
+// Behaviour rules carried over from the design:
+// - Desktop Enter sends EVERYWHERE except inside a code fence (the one
+// context that explicitly means "multi-line content"); Shift+Enter — and
+// touch Enter — are structural: newline in a fence, next item in a list,
+// hard_break in a paragraph. The keyboard NEVER splits paragraphs (wire
+// bytes: hard_break is "\n"; "\n\n" only ever comes from edit-loads).
+// - "Format as you type" (input rules) is a default-ON setting; OFF still
+// leaves ⌘B/⌘I working and literal markdown rendering in the feed. The
+// toggle reconfigures plugins live — history and doc survive.
+// - Backspace runs undoInputRule first: typing *x* then Backspace restores
+// the literal text instead of leaving surprise styling.
+
+import {
+ EditorState, EditorView, TextSelection,
+ history, undo, redo, keymap, baseKeymap,
+ inputRules, InputRule, wrappingInputRule, textblockTypeInputRule, undoInputRule,
+ chainCommands, toggleMark, newlineInCode, wrapIn, lift,
+ wrapInList, splitListItem, liftListItem,
+} from "./vendor/prosemirror.js";
+import {
+ composerSchema as schema,
+ parseToDoc, serializeDoc, docText, textOffsetFromPos, posFromTextOffset,
+} from "./composer-md.js";
+
+// Paste guards (the reverted-native-attempt lesson): a giant paste must never
+// wedge the input. Past 100KB we skip schema/HTML coercion and insert plain
+// text in one transaction; past 1MB we refuse and point at attachments.
+const PASTE_PLAIN_BYTES = 100_000;
+const PASTE_MAX_BYTES = 1_000_000;
+
+// --- input rules ("format as you type") ---------------------------------------
+
+// ProseMirror has no built-in mark input rule — the standard shape: fire on
+// the closing marker, swap the matched text for a marked run, and clear the
+// stored mark so the next keystroke types unstyled (Slack behaviour).
+function markInputRule(regexp, markType) {
+ return new InputRule(regexp, (state, match, start, end) => {
+ const content = match[1];
+ if (!content) return null;
+ const $start = state.doc.resolve(start);
+ if ($start.parent.type.spec.code) return null; // never inside a fence
+ if (state.doc.rangeHasMark(start, end, schema.marks.code)) return null;
+ return state.tr
+ .replaceWith(start, end, schema.text(content, [markType.create()]))
+ .removeStoredMark(markType);
+ });
+}
+
+function buildInputRules() {
+ return inputRules({
+ rules: [
+ // strong before em, so the ** closer never half-fires the * rule
+ markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong),
+ markInputRule(/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/, schema.marks.em),
+ markInputRule(/`([^`\s](?:[^`]*[^`\s])?)`$/, schema.marks.code),
+ markInputRule(/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/, schema.marks.strike),
+ wrappingInputRule(/^\s*([-*])\s$/, schema.nodes.bullet_list),
+ wrappingInputRule(
+ /^(\d+)\.\s$/,
+ schema.nodes.ordered_list,
+ (match) => ({ order: +match[1] }),
+ (match, node) => node.childCount + node.attrs.order === +match[1],
+ ),
+ wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
+ textblockTypeInputRule(/^```$/, schema.nodes.code_block),
+ ],
+ });
+}
+
+// --- the backend ----------------------------------------------------------------
+
+export function createRichComposer(opts = {}) {
+ const insertHardBreak = (state, dispatch) => {
+ if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
+ return true;
+ };
+ // Structural Enter: newline in a fence, next item in a list (an empty item
+ // lifts out), hard_break everywhere else — splitBlock is unreachable, so
+ // the keyboard can never mint a "\n\n".
+ const structuralEnter = chainCommands(newlineInCode, splitListItem(schema.nodes.list_item), insertHardBreak);
+
+ const chatKeymap = keymap({
+ Enter: structuralEnter, // desktop send was already taken upstream (handleKeyDown)
+ "Shift-Enter": structuralEnter,
+ "Mod-b": toggleMark(schema.marks.strong),
+ "Mod-i": toggleMark(schema.marks.em),
+ "Mod-e": toggleMark(schema.marks.code),
+ "Mod-Shift-x": toggleMark(schema.marks.strike),
+ "Mod-Shift-7": wrapInList(schema.nodes.ordered_list),
+ "Mod-Shift-8": wrapInList(schema.nodes.bullet_list),
+ "Mod-Shift-9": chainCommands(wrapIn(schema.nodes.blockquote), lift),
+ "Mod-z": undo,
+ "Mod-Shift-z": redo,
+ "Mod-y": redo,
+ Backspace: chainCommands(undoInputRule, baseKeymap["Backspace"]),
+ });
+
+ let rulesOn = opts.formatAsYouType !== false;
+ const rulesPlugin = buildInputRules();
+
+ // Plugin instances are created ONCE and shared by both configurations, so
+ // the format-as-you-type toggle's reconfigure preserves history state.
+ const basePlugins = [history(), chatKeymap, keymap(baseKeymap)];
+ const pluginsFor = (on) => (on ? [rulesPlugin, ...basePlugins] : basePlugins);
+
+ const freshState = (doc) =>
+ EditorState.create({
+ schema,
+ doc: doc || undefined,
+ plugins: pluginsFor(rulesOn),
+ selection: doc ? TextSelection.atEnd(doc) : undefined,
+ });
+
+ const view = new EditorView(null, {
+ state: freshState(),
+ dispatchTransaction(tr) {
+ view.updateState(view.state.apply(tr));
+ refreshEmpty();
+ if (tr.docChanged && opts.onInput) opts.onInput();
+ },
+ // DIRECT view props run before every plugin: chat.js's keydown layer
+ // (typeahead nav, shouldSend → submit, Escape layering, ↑/↓ edit) sees
+ // keys first; anything it preventDefaulted is consumed, everything else
+ // falls through to the keymaps above.
+ handleKeyDown: (view, e) => {
+ if (opts.onKeydown) opts.onKeydown(e);
+ return e.defaultPrevented;
+ },
+ handlePaste: (view, e) => {
+ const files = [...((e.clipboardData && e.clipboardData.files) || [])];
+ if (files.length) {
+ if (opts.onPasteFiles) opts.onPasteFiles(files);
+ return true;
+ }
+ const text = e.clipboardData && e.clipboardData.getData("text/plain");
+ if (text && text.length > PASTE_MAX_BYTES) {
+ if (opts.onOversizePaste) opts.onOversizePaste();
+ return true;
+ }
+ if (text && text.length > PASTE_PLAIN_BYTES) {
+ // One plain-text transaction — no HTML parse, no schema coercion.
+ view.dispatch(view.state.tr.insertText(text).scrollIntoView());
+ return true;
+ }
+ return false; // ProseMirror's paste (rich HTML degrades through the schema)
+ },
+ handleDrop: (view, e) => {
+ const files = [...((e.dataTransfer && e.dataTransfer.files) || [])];
+ if (files.length) {
+ if (opts.onDropFiles) opts.onDropFiles(files);
+ return true;
+ }
+ return false;
+ },
+ attributes: {
+ class: "composer-input",
+ role: "textbox",
+ "aria-multiline": "true",
+ },
+ });
+ // Caret moves (click / arrows) can enter or leave a typeahead token —
+ // ProseMirror leaves these DOM events alone, so plain listeners match the
+ // textarea backend's wiring exactly.
+ if (opts.onKeyup) view.dom.addEventListener("keyup", opts.onKeyup);
+ if (opts.onClick) view.dom.addEventListener("click", opts.onClick);
+ view.dom.addEventListener("focus", () => opts.onFocus && opts.onFocus());
+ view.dom.addEventListener("blur", () => opts.onBlur && opts.onBlur());
+
+ const isEmptyDoc = () => docText(view.state.doc).trim() === "";
+ const refreshEmpty = () => view.dom.classList.toggle("pm-empty", isEmptyDoc());
+ if (opts.placeholder) view.dom.setAttribute("data-placeholder", opts.placeholder);
+ refreshEmpty();
+
+ // Silent state swaps (no dispatchTransaction → no onInput) for programmatic
+ // writes; { notify: true } fires the same change pipeline typing does.
+ const swapDoc = (doc, o = {}) => {
+ view.updateState(freshState(doc));
+ refreshEmpty();
+ if (o.notify && opts.onInput) opts.onInput();
+ };
+
+ // Plain text in (native pushback etc.): literal paragraph + hard_breaks —
+ // NOT markdown-parsed, so predictable for text that came from getText().
+ const plainDoc = (text) => {
+ const parts = String(text || "").split("\n");
+ const children = [];
+ parts.forEach((line, i) => {
+ if (i > 0) children.push(schema.nodes.hard_break.create());
+ if (line) children.push(schema.text(line));
+ });
+ return schema.node("doc", null, [schema.node("paragraph", null, children)]);
+ };
+
+ return {
+ kind: "rich",
+ el: view.dom,
+ view, // exposed for the live settings toggle (composer.js registry)
+ focus() {
+ view.focus();
+ },
+ hasFocus() {
+ return view.hasFocus();
+ },
+ isEmpty() {
+ return isEmptyDoc();
+ },
+ getText() {
+ return docText(view.state.doc);
+ },
+ getCaret() {
+ return textOffsetFromPos(view.state.doc, view.state.selection.from);
+ },
+ getSelectionEnd() {
+ return textOffsetFromPos(view.state.doc, view.state.selection.to);
+ },
+ setText(v, o = {}) {
+ swapDoc(plainDoc(v), o);
+ },
+ replaceRange(start, end, insert) {
+ const doc = view.state.doc;
+ const from = posFromTextOffset(doc, start);
+ const to = posFromTextOffset(doc, end);
+ let tr = view.state.tr.insertText(insert, from, to);
+ tr = tr.setSelection(TextSelection.create(tr.doc, from + insert.length));
+ view.dispatch(tr.scrollIntoView());
+ view.focus();
+ },
+ insertAtCaret(text) {
+ view.dispatch(view.state.tr.insertText(text).scrollIntoView());
+ view.focus();
+ },
+ getMarkdown() {
+ return serializeDoc(view.state.doc);
+ },
+ setMarkdown(v, o = {}) {
+ swapDoc(parseToDoc(v), o);
+ },
+ clear() {
+ swapDoc(null); // fresh state, fresh history — ⌘Z can't resurrect a sent message
+ },
+ setPlaceholder(s) {
+ view.dom.setAttribute("data-placeholder", s || "");
+ },
+ setAriaLabel(s) {
+ view.dom.setAttribute("aria-label", s || "");
+ },
+ setIndent(px) {
+ view.dom.style.paddingLeft = px ? `${px}px` : "";
+ },
+ inCodeBlock() {
+ return view.state.selection.$from.parent.type.name === "code_block";
+ },
+ setFormatAsYouType(on) {
+ if (on === rulesOn) return;
+ rulesOn = on;
+ // reconfigure keeps state for plugins present on both sides — the doc
+ // and undo history survive the toggle.
+ view.updateState(view.state.reconfigure({ plugins: pluginsFor(on) }));
+ },
+ destroy() {
+ view.destroy();
+ },
+ };
+}
diff --git a/web/static/composer.js b/web/static/composer.js
index 64e9b78..44d9189 100644
--- a/web/static/composer.js
+++ b/web/static/composer.js
@@ -39,11 +39,48 @@ export function anyComposerHasText() {
}
export function createComposer(opts = {}) {
- const handle = createPlainComposer(opts);
+ return adoptComposer(createPlainComposer(opts));
+}
+
+// The rich backend (composer-pm.js) is dynamically imported by chat.js — it
+// registers its handles here so the draft guard and live settings see every
+// composer regardless of backend.
+export function adoptComposer(handle) {
live.add(new WeakRef(handle));
return handle;
}
+// --- composer preferences (localStorage, like the theme prefs in shell.js) ---
+
+const FMT_KEY = "lchat_fmt_as_you_type";
+const CLASSIC_KEY = "lchat_classic_composer";
+
+// Default ON: absent means enabled.
+export function formatAsYouTypePref() {
+ return localStorage.getItem(FMT_KEY) !== "0";
+}
+export function setFormatAsYouType(on) {
+ localStorage.setItem(FMT_KEY, on ? "1" : "0");
+ // Live-reconfigure every mounted composer — no remount, history survives.
+ for (const ref of live) {
+ const c = ref.deref();
+ if (!c) {
+ live.delete(ref);
+ continue;
+ }
+ c.setFormatAsYouType(on);
+ }
+}
+
+// The escape hatch: force the plain textarea backend (picked up on the next
+// composer mount — shell.js reopens the pane when toggled).
+export function classicComposerPref() {
+ return localStorage.getItem(CLASSIC_KEY) === "1";
+}
+export function setClassicComposer(on) {
+ localStorage.setItem(CLASSIC_KEY, on ? "1" : "0");
+}
+
// 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) {
diff --git a/web/static/shell.js b/web/static/shell.js
index 4927f8e..f4f20bf 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -175,7 +175,7 @@ import {
getBackfillMark as storeBackfillMark,
runBackfill as storeRunBackfill,
} from "/static/store.js";
-import { anyComposerHasText } from "/static/composer.js";
+import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } 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
@@ -539,6 +539,38 @@ function showThemePicker() {
card.appendChild(row);
}
+ // Composer preferences (#531): "Format as you type" applies live to every
+ // mounted composer; "Classic composer" (the plain-textarea escape hatch)
+ // takes effect on the next thread open — reopening the current one here so
+ // the choice is visible immediately.
+ const composerLabel = document.createElement("label");
+ composerLabel.className = "field-label";
+ composerLabel.textContent = "Composer";
+ composerLabel.style.marginTop = "14px";
+ card.appendChild(composerLabel);
+ const toggleRow = (labelText, checked, onchange) => {
+ const row = document.createElement("label");
+ row.className = "composer-pref-row";
+ const box = document.createElement("input");
+ box.type = "checkbox";
+ box.checked = checked;
+ box.onchange = () => onchange(box.checked);
+ const txt = document.createElement("span");
+ txt.textContent = labelText;
+ row.append(box, txt);
+ card.appendChild(row);
+ return row;
+ };
+ toggleRow("Format as you type (**bold** becomes bold)", formatAsYouTypePref(), (on) => setFormatAsYouType(on));
+ toggleRow("Classic plain-text composer", classicComposerPref(), (on) => {
+ setClassicComposer(on);
+ // Remount the open thread's pane so the switch is immediate. Parked panes
+ // rebuild on their next open (appPaneCache entries are dropped).
+ for (const k of Object.keys(appPaneCache)) delete appPaneCache[k];
+ const active = localStorage.getItem(ACTIVE_KEY);
+ if (active && typeof openThreadById === "function") openThreadById(active);
+ });
+
const foot = document.createElement("div");
foot.className = "sheet-foot";
const done = document.createElement("button");
diff --git a/web/static/style.css b/web/static/style.css
index 632a438..0e3bc3f 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -340,7 +340,7 @@ input, textarea {
input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
/* the message box doesn't get the bright focus ring (chat-app convention) — the
coloured caret is the cue. */
-.composer textarea:focus { outline: none; border-color: var(--border); }
+.composer .composer-input:focus { outline: none; border-color: var(--border); }
/* ---- centered card layout (join / fallback) ---- */
.center {
@@ -1551,7 +1551,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
variable from the base rule; the two declarations after it restore what
the shorthand resets. Higher-specificity sizes (.jumbo-emoji) still win. */
.msg .bubble { font: -apple-system-body; font-family: inherit; line-height: 1.35; }
- .composer textarea { font-size: 17px; }
+ .composer .composer-input { font-size: 17px; }
.thread-row-name { font-size: 17px; }
.thread-row-preview { font-size: 14px; }
}
@@ -1590,7 +1590,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
@media (prefers-reduced-motion: reduce) {
.composer-label { transition: none; }
}
-.composer textarea {
+.composer .composer-input {
flex: 1;
resize: none;
max-height: 140px;
@@ -1612,6 +1612,66 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
/* The shared lifted-panel input fill (see --input-bg / .eleven-input). */
background: var(--input-bg);
}
+/* The rich composer (composer-pm.js): a ProseMirror contenteditable wearing
+ the same skin. Auto-grow is pure CSS here — min/max-height + overflow — so
+ there is no JS measuring to go stale (the lesson from the reverted native
+ attempt). The textarea's implicit border comes back explicitly. */
+.composer .composer-input[contenteditable] {
+ position: relative;
+ border: 1px solid var(--border);
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ color: var(--ink);
+ caret-color: var(--accent);
+}
+.composer .composer-input[contenteditable] p { margin: 0; }
+/* In-composer blocks: the bubble treatments at composer scale. */
+.composer .composer-input[contenteditable] ul,
+.composer .composer-input[contenteditable] ol { margin: 2px 0; padding-left: 22px; }
+.composer .composer-input[contenteditable] blockquote {
+ margin: 2px 0;
+ padding: 1px 0 1px 10px;
+ border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent);
+ color: color-mix(in srgb, var(--ink) 72%, transparent);
+}
+.composer .composer-input[contenteditable] pre {
+ margin: 2px 0;
+ padding: 6px 8px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--ink) 8%, transparent);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.92em;
+ overflow-x: auto;
+ white-space: pre;
+}
+.composer .composer-input[contenteditable] code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.92em;
+ background: color-mix(in srgb, var(--ink) 8%, transparent);
+ padding: 1px 5px;
+ border-radius: 5px;
+}
+.composer .composer-input[contenteditable] pre code { background: none; padding: 0; font-size: 1em; }
+/* Placeholder: contenteditable has no native one — the pm-empty class is
+ maintained by the backend; float keeps the caret on the first line. */
+/* Theme-sheet composer preference rows (#531) */
+.composer-pref-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 8px 0;
+ font-size: 14px;
+ color: var(--ink);
+ cursor: pointer;
+}
+.composer-pref-row input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; margin: 0; }
+.composer .composer-input.pm-empty::before {
+ content: attr(data-placeholder);
+ color: var(--muted);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
/* eleven-input — the reusable text-input surface: the same lifted-panel fill the
chat composer uses, so inputs across the app and its mini-apps read as one.