From d6882a895e69b29ae22e0a8777482294ab47131f Mon Sep 17 00:00:00 2001 From: Mostafa Eid <150278458+lleoparden@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:06:05 +0300 Subject: [PATCH] feat(chat): recall last user message on empty composer ArrowUp (#1175) Pressing ArrowUp on an empty #message composer restores the last sent user text, matching common chat-app UX (Slack, Discord, ChatGPT). - Read from #chat-history .msg-user dataset.raw (same path as resend/regenerate), not session sidebar metadata - Literal empty check (whitespace-only drafts are preserved); ignore Shift/Alt/Ctrl/Meta and IME composition - Extract wiring to composerArrowUpRecall.js; rAF + 250ms retry only (no global MutationObserver) - Add tests/test_composer_arrow_up_recall_js.py Co-authored-by: Cursor --- static/js/chat.js | 15 ++ static/js/composerArrowUpRecall.js | 61 +++++ tests/test_composer_arrow_up_recall_js.py | 277 ++++++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 static/js/composerArrowUpRecall.js create mode 100644 tests/test_composer_arrow_up_recall_js.py diff --git a/static/js/chat.js b/static/js/chat.js index 1b2185c36..010f78312 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -24,6 +24,8 @@ import codeRunnerModule from './codeRunner.js'; import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js'; import createResearchSynapse from './researchSynapse.js'; import { createStreamRenderer } from './streamingRenderer.js'; +import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composerArrowUpRecall.js'; + const RESEARCH_TIMEOUT_MS = 360000; const DEFAULT_TIMEOUT_MS = 120000; const RESEARCH_SVG = ''; @@ -217,6 +219,19 @@ import { createStreamRenderer } from './streamingRenderer.js'; const ta = document.getElementById('message'); if (ta && mod.initSlashAutocomplete) mod.initSlashAutocomplete(ta); }).catch(() => {}); + + // ArrowUp on empty composer recalls last user message (like many chat apps). + const _wireArrowUpRecall = (composer) => + wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(), { + autoResize: uiModule?.autoResize, + }); + + const composer = document.getElementById('message'); + if (!_wireArrowUpRecall(composer)) { + // Init can run before #message exists (templated UI); short retries only. + try { requestAnimationFrame(() => _wireArrowUpRecall(document.getElementById('message'))); } catch (_) {} + setTimeout(() => _wireArrowUpRecall(document.getElementById('message')), 250); + } } // addMessage, createMsgFooter, displayMetrics, hideWelcomeScreen, showWelcomeScreen diff --git a/static/js/composerArrowUpRecall.js b/static/js/composerArrowUpRecall.js new file mode 100644 index 000000000..a572185c3 --- /dev/null +++ b/static/js/composerArrowUpRecall.js @@ -0,0 +1,61 @@ +/** + * ArrowUp on an empty composer recalls the last user message (chat-app convention). + */ + +/** + * Last user bubble in the active chat surface (#chat-history), using dataset.raw + * (same source as resend/regenerate in chat.js). + * + * @param {Document | Element} [root=document] + * @returns {string} + */ +export function getLastUserMessageFromChatHistory(root = document) { + const chatBox = + root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function' + ? root + : (root.getElementById ? root.getElementById('chat-history') : null); + if (!chatBox) return ''; + + const users = chatBox.querySelectorAll('.msg-user'); + const last = users[users.length - 1]; + if (!last) return ''; + + const bodyEl = last.querySelector('.body'); + return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || ''; +} + +/** + * @param {HTMLTextAreaElement} composer + * @param {() => string} getLastUserMessage + * @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options] + * @returns {boolean} true when wired (or already wired) + */ +export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) { + if (!composer) return false; + if (composer._arrowUpRecallWired) return true; + composer._arrowUpRecallWired = true; + + const { autoResize } = options; + + composer.addEventListener('keydown', (e) => { + // Only ArrowUp, no modifier keys, no IME composition + if (e.key !== 'ArrowUp') return; + if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return; + if (e.isComposing) return; + + // Literal emptiness — intentional whitespace is not empty + if (composer.value !== '') return; + + const recalled = getLastUserMessage(); + if (!recalled) return; + + e.preventDefault(); + composer.value = recalled; + try { + composer.selectionStart = composer.selectionEnd = recalled.length; + } catch (_) {} + if (autoResize) autoResize(composer); + }); + + return true; +} diff --git a/tests/test_composer_arrow_up_recall_js.py b/tests/test_composer_arrow_up_recall_js.py new file mode 100644 index 000000000..7e8164919 --- /dev/null +++ b/tests/test_composer_arrow_up_recall_js.py @@ -0,0 +1,277 @@ +"""Pin ArrowUp recall on the chat composer (static/js/composerArrowUpRecall.js). + +Driven through `node --input-type=module` so we exercise the real JS without a +full Vitest/Jest setup (same approach as test_reply_recipients_js.py). Skips +when `node` is not installed rather than failing. + +Locks in: empty composer recalls last user message; non-empty composer is +untouched; multiline caret navigation is not hijacked; Shift/Alt/Ctrl/Meta+ArrowUp +are ignored; IME composition does not trigger recall; last message is read from +#chat-history (dataset.raw), not session sidebar metadata. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HELPER = _REPO / "static" / "js" / "composerArrowUpRecall.js" +_HELPER_URL = _HELPER.as_uri() +_HAS_NODE = shutil.which("node") is not None + +_HARNESS = r""" +import { wireArrowUpRecall } from 'HELPER_PATH'; + +function makeComposer(initial = '') { + const listeners = []; + const composer = { + value: initial, + selectionStart: initial.length, + selectionEnd: initial.length, + _arrowUpRecallWired: false, + addEventListener(type, fn) { + if (type === 'keydown') listeners.push(fn); + }, + dispatchKey(opts = {}) { + let prevented = false; + const e = { + key: opts.key ?? 'ArrowUp', + shiftKey: !!opts.shiftKey, + altKey: !!opts.altKey, + ctrlKey: !!opts.ctrlKey, + metaKey: !!opts.metaKey, + isComposing: !!opts.isComposing, + preventDefault() { prevented = true; }, + }; + for (const fn of listeners) fn(e); + return prevented; + }, + }; + return composer; +} + +function runCase(body) { + const composer = makeComposer(body.initial ?? ''); + if (body.caret != null) { + composer.selectionStart = body.caret; + composer.selectionEnd = body.caretEnd ?? body.caret; + } + const last = body.last ?? 'previous message'; + let resized = false; + wireArrowUpRecall(composer, () => last, { + autoResize: () => { resized = true; }, + }); + const prevented = composer.dispatchKey(body.event ?? {}); + return { + value: composer.value, + selectionStart: composer.selectionStart, + selectionEnd: composer.selectionEnd, + prevented, + resized, + }; +} + +const cases = CASES_JSON; +const results = cases.map(runCase); +console.log(JSON.stringify(results)); +""".replace("HELPER_PATH", _HELPER_URL) + + +def _run(cases: list) -> list: + js = _HARNESS.replace("CASES_JSON", json.dumps(cases)) + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_empty_composer_recalls_last_user_message(): + out = _run([{"initial": "", "last": "hello again"}])[0] + assert out["value"] == "hello again" + assert out["selectionStart"] == len("hello again") + assert out["selectionEnd"] == len("hello again") + assert out["prevented"] is True + assert out["resized"] is True + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_non_empty_composer_does_not_recall(): + out = _run([{"initial": "draft in progress", "last": "ignored"}])[0] + assert out["value"] == "draft in progress" + assert out["prevented"] is False + assert out["resized"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_whitespace_only_composer_is_not_empty(): + out = _run([{"initial": " ", "last": "ignored"}])[0] + assert out["value"] == " " + assert out["prevented"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_multiline_caret_navigation_preserved(): + # Caret on line 2 — ArrowUp must not recall or preventDefault. + text = "line one\nline two" + out = _run([{"initial": text, "caret": len(text), "last": "ignored"}])[0] + assert out["value"] == text + assert out["selectionStart"] == len(text) + assert out["prevented"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_modified_arrow_up_ignored(): + cases = [ + {"initial": "", "event": {"shiftKey": True}}, + {"initial": "", "event": {"altKey": True}}, + {"initial": "", "event": {"ctrlKey": True}}, + {"initial": "", "event": {"metaKey": True}}, + ] + for out in _run(cases): + assert out["value"] == "" + assert out["prevented"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_ime_composition_does_not_trigger_recall(): + out = _run([{"initial": "", "event": {"isComposing": True}, "last": "ignored"}])[0] + assert out["value"] == "" + assert out["prevented"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_no_recall_when_last_message_missing(): + out = _run([{"initial": "", "last": ""}])[0] + assert out["value"] == "" + assert out["prevented"] is False + assert out["resized"] is False + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_wire_is_idempotent(): + js = f""" + import {{ wireArrowUpRecall }} from '{_HELPER_URL}'; + const composer = {{ _arrowUpRecallWired: false, addEventListener() {{}} }}; + const ok1 = wireArrowUpRecall(composer, () => 'x'); + const ok2 = wireArrowUpRecall(composer, () => 'y'); + console.log(JSON.stringify({{ ok1, ok2, wired: composer._arrowUpRecallWired }})); + """ + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout.strip()) == {"ok1": True, "ok2": True, "wired": True} + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_get_last_user_message_from_chat_history(): + js = f""" + import {{ getLastUserMessageFromChatHistory }} from '{_HELPER_URL}'; + + const chatBox = {{ + id: 'chat-history', + querySelectorAll(sel) {{ + if (sel !== '.msg-user') return []; + return [ + {{ dataset: {{ raw: 'first' }}, querySelector: () => null }}, + {{ dataset: {{ raw: 'last raw' }}, querySelector: () => null }}, + ]; + }}, + }}; + + const doc = {{ + getElementById(id) {{ return id === 'chat-history' ? chatBox : null; }}, + }}; + + console.log(JSON.stringify({{ + fromChat: getLastUserMessageFromChatHistory(doc), + fromBox: getLastUserMessageFromChatHistory(chatBox), + empty: getLastUserMessageFromChatHistory({{ getElementById: () => null }}), + noUsers: getLastUserMessageFromChatHistory({{ + getElementById: () => ({{ querySelectorAll: () => [] }}), + }}), + }})); + """ + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout.strip()) == { + "fromChat": "last raw", + "fromBox": "last raw", + "empty": "", + "noUsers": "", + } + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_integration_recalls_from_chat_history_dom(): + js = f""" + import {{ + wireArrowUpRecall, + getLastUserMessageFromChatHistory, + }} from '{_HELPER_URL}'; + + const chatBox = {{ + id: 'chat-history', + querySelectorAll(sel) {{ + if (sel !== '.msg-user') return []; + return [{{ dataset: {{ raw: 'stored prompt' }}, querySelector: () => null }}]; + }}, + }}; + const doc = {{ getElementById: (id) => (id === 'chat-history' ? chatBox : null) }}; + + const listeners = []; + const composer = {{ + value: '', + selectionStart: 0, + selectionEnd: 0, + _arrowUpRecallWired: false, + addEventListener(type, fn) {{ if (type === 'keydown') listeners.push(fn); }}, + }}; + wireArrowUpRecall(composer, () => getLastUserMessageFromChatHistory(doc)); + let prevented = false; + listeners[0]({{ + key: 'ArrowUp', + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, + isComposing: false, + preventDefault() {{ prevented = true; }}, + }}); + console.log(JSON.stringify({{ value: composer.value, prevented }})); + """ + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}