feat: Enter sends in the assistant, and the person chooses
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m23s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

Enter made a newline and Ctrl+Enter sent, which is backwards from every chat
people use. Enter now sends by default, with a toggle in the composer's + menu
to put it back.

Two rules hold whatever is chosen, because they are the habits people arrive
with and a setting that broke either would be worse than no setting:
Shift+Enter is always a newline, Ctrl/Cmd+Enter always sends. Both are checked
before the preference, so neither can be switched off.

A keystroke during IME composition never sends. Enter accepts a candidate word
in Chinese, Japanese and Korean, and on predictive Android keyboards; sending
there would cut a sentence off mid-word.

Stored per device rather than per account, because a keyboard preference
belongs to the keyboard: Enter-to-send suits a desk and usually does not suit a
phone, where Enter is how you get a second line. Unset, it defaults by device
class — send where there is a real keyboard, newline on a touch screen — and a
blocked localStorage falls through to that default rather than throwing.

The composer's tooltip says which key sends, where someone already looks when
they wonder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 18:05:36 +02:00
parent b1e039d834
commit 74aa0c1b89
3 changed files with 114 additions and 1 deletions

View file

@ -91,6 +91,14 @@
<button id="btn-assistant-takehome" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-heart"></i> Patient take home</button>
<button id="btn-assistant-export-pdf" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-file-pdf"></i> Export PDF</button>
<button id="btn-assistant-download-chat" class="assistant-plus-item" type="button" role="menuitem"><i class="fas fa-download"></i> Download transcript</button>
<!-- A keyboard preference belongs to the keyboard, so it
lives beside the composer rather than in Settings,
and is remembered per device: Enter-to-send suits a
desk and often does not suit a phone. -->
<label class="assistant-plus-item" role="menuitemcheckbox" style="cursor:pointer;">
<input type="checkbox" id="assistant-enter-sends" style="margin-right:8px;">
Enter sends the message
</label>
</div>
</div>
<input type="file" id="assistant-attach-input" accept="image/png,image/jpeg,image/webp" multiple hidden>

View file

@ -137,6 +137,35 @@ import {
document.addEventListener(type, handler);
}
// Per device, not per account: a keyboard preference belongs to the keyboard.
// Someone who wants Enter to send at a desk usually does not want it on a
// phone, where Enter is how you get a second line in a note.
var ENTER_KEY = 'ped_assistant_enter_sends';
function enterSends() {
try {
var saved = localStorage.getItem(ENTER_KEY);
if (saved === '1') return true;
if (saved === '0') return false;
} catch (e) { /* private window, blocked storage: fall through to the default */ }
// Unset: send on a device with a real keyboard, newline on a touch one.
try { return !window.matchMedia('(hover: none) and (pointer: coarse)').matches; }
catch (e) { return true; }
}
function setEnterSends(on) {
try { localStorage.setItem(ENTER_KEY, on ? '1' : '0'); } catch (e) {}
}
// Say which key sends, where someone is already looking when they wonder.
function applyEnterHint() {
var input = document.getElementById('assistant-input');
if (!input) return;
input.title = enterSends()
? 'Enter sends · Shift+Enter for a new line'
: 'Enter for a new line · Ctrl+Enter sends';
}
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
@ -319,10 +348,35 @@ import {
if (attachInput) attachInput.addEventListener('change', onAttachFiles);
document.addEventListener('click', onAssistantDocumentClick);
document.addEventListener('keydown', onAssistantKeydown);
// Enter: send, or newline. Two rules never change, whatever the preference —
// Shift+Enter is always a newline, and Ctrl/Cmd+Enter always sends. Those
// are the muscle memory people arrive with, and a setting that broke either
// would be worse than no setting.
//
// Composition matters: an IME (Chinese, Japanese, Korean, and predictive
// keyboards on Android) uses Enter to accept a candidate word. Sending on
// that would cut a sentence in half mid-word, so a keystroke during
// composition is never a send.
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
if (e.key !== 'Enter') return;
if (e.isComposing || e.keyCode === 229) return;
if ((e.ctrlKey || e.metaKey)) { e.preventDefault(); onAsk(e); return; }
if (e.shiftKey || e.altKey) return; // newline, always
if (!enterSends()) return; // newline, by preference
e.preventDefault();
onAsk(e);
});
var enterToggle = document.getElementById('assistant-enter-sends');
if (enterToggle) {
enterToggle.checked = enterSends();
enterToggle.addEventListener('change', function () {
setEnterSends(enterToggle.checked);
applyEnterHint();
});
}
applyEnterHint();
bindExampleButtons(document);
loadSavedChats();
documentListenersBound = true; // every delegated listener above is now registered

View file

@ -0,0 +1,51 @@
// Enter sends, or makes a newline, and the person decides. Two rules hold
// whatever they choose: Shift+Enter is always a newline, Ctrl/Cmd+Enter always
// sends. Those are the habits people arrive with.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/clinicalAssistant.js'), 'utf8');
const handler = src.slice(src.indexOf("input.addEventListener('keydown'"), src.indexOf('var enterToggle'));
test('Ctrl or Cmd + Enter always sends, whatever the preference', () => {
assert.match(handler, /if \(\(e\.ctrlKey \|\| e\.metaKey\)\) \{ e\.preventDefault\(\); onAsk\(e\); return; \}/);
// It is checked before the preference, so it cannot be switched off.
assert.ok(handler.indexOf('ctrlKey') < handler.indexOf('enterSends()'),
'the modifier must win over the preference');
});
test('Shift+Enter is always a newline, whatever the preference', () => {
assert.match(handler, /if \(e\.shiftKey \|\| e\.altKey\) return;/);
assert.ok(handler.indexOf('shiftKey') < handler.indexOf('enterSends()'));
});
test('an IME composition keystroke never sends', () => {
// Enter accepts a candidate word in Chinese, Japanese, Korean and on
// predictive Android keyboards. Sending there cuts a sentence mid-word.
assert.match(handler, /if \(e\.isComposing \|\| e\.keyCode === 229\) return;/);
assert.ok(handler.indexOf('isComposing') < handler.indexOf('ctrlKey'),
'composition is checked before anything can send');
});
test('the preference is per device, and survives storage being unavailable', () => {
const pref = src.slice(src.indexOf('function enterSends()'), src.indexOf('function applyEnterHint'));
assert.match(pref, /localStorage\.getItem\(ENTER_KEY\)/);
assert.match(pref, /catch \(e\)/, 'a private window must not throw here');
// Unset defaults by device class rather than guessing one answer for both.
assert.match(pref, /\(hover: none\) and \(pointer: coarse\)/);
});
test('the toggle reflects and writes the preference', () => {
const bind = src.slice(src.indexOf('var enterToggle'), src.indexOf('bindExampleButtons'));
assert.match(bind, /enterToggle\.checked = enterSends\(\)/, 'opens showing the current state');
assert.match(bind, /setEnterSends\(enterToggle\.checked\)/);
const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/assistant.html'), 'utf8');
assert.match(html, /id="assistant-enter-sends"/);
});
test('the composer says which key sends', () => {
assert.match(src, /Enter sends · Shift\+Enter for a new line/);
assert.match(src, /Enter for a new line · Ctrl\+Enter sends/);
});