mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user