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-editor →
src/MeshWeaver.Blazor/wwwroot/lib/monaco-editor:
standalone/browser/standaloneCodeEditor.js—addCommand(keybinding, handler, context)forwards to_standaloneKeybindingService, one page-wide singleton, withwhen = ContextKeyExpr.deserialize(context). The call sites passed no context, sowhenisundefined: the binding is scoped to no editor at all.standalone/browser/standaloneServices.js—addDynamicKeybindingsappends to one page-wide array and returns a disposable that would remove the entry.addCommanddiscards it and returns only a string id, so nothing can ever unregister one.dispose(editorId)in our module does not try to, and could not.platform/keybinding/common/keybindingResolver.js—_findCommandscans the matching entries backwards (for (let i = matches.length - 1; i >= 0; i--)). Every one of ours matched everything, so the last registration wins.platform/keybinding/common/abstractKeybindingService.js— a resolved keybinding setsshouldPreventDefault, so the intercepted chord could not fall through to Monaco's own newline either. Interception without delivery: the worst of both.
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:
- Does it fail with a single editor ever mounted on the page (fresh reload, go straight to a thread, touch nothing else)? Pre-#2081 that case worked — the one global registration happened to point at the right editor. A failure there is not #2081.
- Does it start failing only after the search box, another thread, or any second composer has been opened? That was #2081's signature.
- Does it start failing only after a
/or@picker has been opened? That is #2217's signature, and the thing to check is_focusComposerOnRender.
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.