diff --git a/public/components/assistant.html b/public/components/assistant.html
index 2a54a469..e99bdaff 100644
--- a/public/components/assistant.html
+++ b/public/components/assistant.html
@@ -91,6 +91,14 @@
+
+
diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 1db5b848..fbf454af 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -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
diff --git a/test/assistant-enter-to-send.test.js b/test/assistant-enter-to-send.test.js
new file mode 100644
index 00000000..9c86201d
--- /dev/null
+++ b/test/assistant-enter-to-send.test.js
@@ -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/);
+});