Shift+Enter in the chat composer, and the two different ways it went missing

The same user-visible symptom — "Shift+Enter sometimes inserts a line break and sometimes does nothing at all" — has had two entirely unrelated causes. Both are fixed. This page exists so the third report is diagnosed rather than assumed, because the obvious reading ("#2217 regressed") was wrong the second time and cost the investigation its first hour.

MeshWeaver#2217 (fixed by MeshWeaver#2252, 2026-08-25) plugins#2081 (fixed 2026-09-18)
Where ThreadChatView.razor.cs MonacoEditorView.razor.js
Cause the / or @ picker took focus off Monaco and no close path gave it back the newline came from a page-global Monaco keybinding that the last-created editor on the page won
"Sometimes" means whenever a picker had been opened just before whenever another chat-mode editor had been mounted after this one
Still present? yes — _focusComposerOnRender, five sites, unchanged n/a, the mechanism is gone

The two compose: either one alone is enough to make the keystroke vanish, and neither leaves a log line, a console error or anything to grep. Confirming that one is fixed says nothing about the other.

Why #2081 was not a regression of #2217

_focusComposerOnRender and its five sites were all present and correct when #2081 was filed; that was checked first. The defect was one level down, in how the newline was bound at all.

MonacoEditorView.razor.js handled the two halves of the Enter family by two different mechanisms, and only one of them was tied to the editor that received the keystroke:

Key Mechanism Scope
Enter (submit) editorInstance.onKeyDown(...) the editor instance — fires only for the editor that got the keystroke
Shift+Enter / Alt+Enter (newline) editorInstance.addCommand(KeyMod.Shift \| KeyCode.Enter, ...) the whole page

addCommand reads as if it registers something on the editor. It does not. Measured in monaco-editor 0.56.0 — the build the portal vendors and serves, tools/monaco-editorsrc/MeshWeaver.Blazor/wwwroot/lib/monaco-editor:

So Shift+Enter ran the handler belonging to whichever chat-mode editor was created last on the page, and called trigger('keyboard', 'type', { text: '\n' }) on that instance — typing the newline into an editor the user was not looking at, or nowhere at all once it had been disposed.

CodeEditMode defaults to false, and SearchBoxView and MeshSearchView both pass CodeEditMode="false" explicitly, so the chat composer is not the only editor that registered one. Opening the search box once was enough to take Shift+Enter away from the composer for the rest of the session.

That is the entire "intermittently". It turned on what had been mounted last, never on what was typed — which is exactly why the reporter saw no correlation with message length, and why the same build both failed and worked.

The fix: one mechanism for the whole Enter family

Both addCommand registrations are gone. Enter, Shift+Enter and Alt+Enter are now decided in the single onKeyDown listener, which is per editor instance and therefore cannot be won by another editor:

editorInstance.onKeyDown(async (e) => {
    if (e.keyCode !== monaco.KeyCode.Enter) return;
    if (e.ctrlKey || e.metaKey) return;              // Monaco's, not the composer's
    if (e.shiftKey || e.altKey) {                    // newline, in THIS editor
        e.preventDefault();
        e.stopPropagation();
        editorInstance.trigger('keyboard', 'type', { text: '\n' });
        return;
    }
    // …suggest-widget guard (issue #174), then preventDefault + HandleSubmit
});

The newline action is unchanged — only where it is bound from moves, out of the page-global registry and into this editor's own listener. preventDefault + stopPropagation keep Monaco's own bindings off the chord: the editor's keydown listener fires on the hidden textarea, inside the container the keybinding service listens on, so stopping propagation there means the service never sees it.

Two further properties come free: the per-page registration leak is gone (every chat editor ever mounted used to leave two permanently unremovable entries behind), and a chat composer can no longer steal Shift+Enter from a code editor elsewhere on the page — the old binding was unscoped, so it did.

The wrong turn: "just let Monaco's default insert the newline"

The obvious simplification is to delete the addCommands and return from the handler without preventing the chord, on the reasoning that Monaco inserts a newline on Shift+Enter anyway. That is wrong, and it would have replaced one silent defect with another in the very scenario #2081 was reported from. It was written, reviewed, measured and reverted.

The vendored bundle is the evidence. wwwroot/lib/monaco-editor/monaco.js binds both chords:

chord numeric Monaco command live when
Shift+Enter primary:1027 acceptAlternativeSelectedSuggestion suggestWidgetVisible && textInputFocus && HasFocusedSuggestion
Shift+Enter primary:1027 find widget PreviousMatchFindAction find input focused
Alt+Enter primary:515 find widget SelectAllMatchesAction find widget open

So an unprevented Shift+Enter with the suggest list up accepts the suggestion instead of breaking the line — and the composer runs a completion provider continuously for @ references and / slash commands, so that list is open most of the time the user is typing a command. The old addCommand had been hiding this: its dynamic keybinding carries weight1: 1000, above EditorContrib's 100, so it outranked acceptAlternativeSelectedSuggestion and the newline won. Removing it without claiming the chord would have handed that case to Monaco.

How to check this yourself, on the committed bundle, with no browser and no npm install:

grep -o 'primary:1027' src/MeshWeaver.Blazor/wwwroot/lib/monaco-editor/monaco.js | wc -l

The keybinding numbers are KeyCode | KeyMod sums — Enter is 3, Shift 1024, Alt 512, CtrlCmd 2048 — and esbuild leaves the folded literals and the primary: keys intact, so the whole default keymap is greppable. Searching the ESM sources for KeyMod.Shift | KeyCode.Enter finds nothing and reads as "no default exists"; that is a false negative, and it is what produced the wrong turn.

It was live, not merely in the tree

plugins#2081 left one thing open: /api/version names the portal image's core commit, not the vintage of the MeshWeaver.Blazor.Chat view pack baked into it, so the report could in principle have been an undeployed fix rather than a live defect.

It was live. The composer asset is served unauthenticated and can be read directly — measured 2026-09-18, both portals returned HTTP 200, 67,660 bytes, and the body contained the addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter registration:

curl -s https://memex.systemorph.com/_content/MeshWeaver.Blazor/Components/Monaco/MonacoEditorView.razor.js \
  | grep -c 'addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter'

That is the cheapest way to ask "does the running portal carry this composer code", and it needs no credential and no cluster access. It is a point-in-time reading — re-run it, do not cite this one.

What to measure before concluding anything, next time

The two causes are distinguished by one observation, and it takes seconds:

Do not reach for a retry, a debounce or a setTimeout for any of them. Neither cause was a timing race: both were deterministic given the page's history, and both reproduce 100% of the time once that history is stated.

The regression test

clients/react/src/controls/composerEnterKeys.test.ts drives the real MonacoEditorView.razor.js — the shipped file, evaluated verbatim, not a copy — through a recording editor double and a keybinding registry that follows the four Monaco sources cited above. It runs in CI with the rest of the renderer suite.

The decisive case does not depend on modelling Monaco at all: it mounts two chat-mode editors, focuses the first, presses Shift+Enter, and asserts the newline landed in that editor and not in the other. Pre-fix it failed whichever way the disposed-versus-live question was modelled, because the winning registration pointed at the wrong editor either way.

Every newline it asserts is produced by the production module calling trigger — recorded by the editor double, never supplied by the harness. That distinction is the point: an earlier draft let the harness append the newline whenever the chord went unclaimed, which made the newline tests pass whether or not anything actually inserted one. A test that cannot fail is not a test.

Measured against three variants of the module, deterministic each time:

variant of MonacoEditorView.razor.js result
the original (origin/main) 4 failed, 6 passed — the page-global registration cases
the fall-through wrong turn 1 failed, 9 passed — exactly the suggest-widget case
the fix 10 passed

The six that pass against the original are the ones that genuinely worked — a single composer on the page, plain Enter submitting, the suggest-widget guard — so they document the intermittency rather than merely surviving it. The middle row is why the wrong turn is worth keeping a test for: it is a one-line change that nine of ten assertions are blind to.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.