Merge pull request #5897 from nesquena/release/stage-5887
Release: focus composer after URL prefill (#5887)
This commit is contained in:
4 files changed
+82
-15
No files matched your search
@@ -5,6 +5,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Opening Hermes from a browser-search or launcher deep-link now focuses the composer so you can press Enter immediately.** A `?q=`/`?prompt=` URL prefill populated the composer but left keyboard focus elsewhere, so the prefilled text sat there until you clicked into the box. Boot now focuses the composer after applying a non-empty URL prefill — the focus fires only on that prefill seam (ordinary boots are unchanged) and never auto-sends. Thanks @rodboev. (#5887, #5884)
|
||||
|
||||
- **A fast session switch can no longer paint the wrong conversation's messages into the pane you're looking at.** The chat pane is shared state, and `loadSession()` previously tracked ownership only by destination session id. If you switched sessions (or reloaded the same one) quickly, a slow transcript response from the earlier load could finish late and populate `S.messages` after a newer navigation had already taken over — leaving a split pane: the current session's header above another session's rows. Each `loadSession()` invocation now carries a monotonically increasing generation token, and every point that mutates the pane after an `await` re-checks it still owns the load (matching both session id *and* generation) before writing — so a stale continuation bails instead of hijacking the view. A redundant same-session click no longer cancels the authoritative in-flight refresh, and a stale response can't collapse a long transcript to its tail window. Thanks @starship-s. (#5889)
|
||||
|
||||
- **The model picker no longer mislabels a model as free when OpenRouter hasn't published complete pricing.** The OpenRouter free-tier detection treated a missing or `null` price field as `0`, so a model whose pricing metadata was absent or partial could be surfaced as "(free)" and added to the free-tier list. The check now fails closed — a candidate is only treated as free when both prompt and completion prices are present and genuinely zero — while models with an explicit `:free` id stay selectable. Thanks @igorroncevic. (#5882, #5880)
|
||||
|
||||
@@ -130,6 +130,7 @@ async function _applyComposerPrefillOnBoot(prefillIntent){
|
||||
msg.value=text;
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
else if(typeof updateSendBtn==='function') updateSendBtn();
|
||||
if(typeof msg.focus==='function') msg.focus();
|
||||
}
|
||||
async function _finalizeComposerPrefillOnBoot(prefillIntent){
|
||||
if(prefillIntent&&prefillIntent.hasParams&&typeof _consumeComposerPrefillParamsFromLocation==='function'){
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Reproduction source: issue-artifact copied from https://github.com/nesquena/hermes-webui/issues/5884
|
||||
|
||||
1. Open `https://<your-hermes-host>/?q=hello+world`
|
||||
2. Wait for the page to finish booting
|
||||
3. Press Enter
|
||||
|
||||
Observed on current `origin/master` `d7c3c2b7`: the composer is prefilled, but the prompt is not submitted because `#msg` never took focus.
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Regression tests for #4961 URL query composer prefill behavior."""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -9,6 +10,7 @@ import pytest
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
|
||||
BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js"
|
||||
REPRO_PATH = REPO_ROOT / "tests" / "fixtures" / "webui-PR-TARGET-5884-REPRO.md"
|
||||
SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
@@ -31,6 +33,13 @@ def _run_node(source: str) -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _recorded_repro_query() -> str:
|
||||
repro = REPRO_PATH.read_text(encoding="utf-8")
|
||||
match = re.search(r"\?q=hello\+world", repro)
|
||||
assert match, "expected recorded query in repo fixture"
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def _node_prelude() -> str:
|
||||
return f"""
|
||||
const sessionsSrc = {SESSIONS_JS!r};
|
||||
@@ -181,12 +190,13 @@ def test_fresh_default_workspace_bind_skips_prefill_draft_boot():
|
||||
}
|
||||
|
||||
|
||||
def test_apply_prefill_updates_composer_without_autosending():
|
||||
source = _node_prelude() + """
|
||||
def test_boot_query_prefill_focuses_composer_without_sending():
|
||||
recorded_query = _recorded_repro_query()
|
||||
source = (_node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, send: 0 };
|
||||
const msg = { value: '' };
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, focus: 0, send: 0 };
|
||||
const msg = { value: '', focus() { counts.focus++; document.activeElement = this; } };
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
return id === 'msg' ? msg : null;
|
||||
@@ -196,19 +206,63 @@ def test_apply_prefill_updates_composer_without_autosending():
|
||||
global.autoResize = () => { counts.autoResize++; };
|
||||
global.updateSendBtn = () => { counts.updateSendBtn++; };
|
||||
global.send = async () => { counts.send++; };
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: 'hello world', autoSend: true });
|
||||
delete global.autoResize;
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: 'second pass', autoSend: false });
|
||||
await _applyComposerPrefillOnBoot({ hasText: false, text: ' ', autoSend: true });
|
||||
console.log(JSON.stringify({ counts, value: msg.value }));
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: new URL('https://example.test/' + RECORDED_QUERY).searchParams.get('q'), autoSend: false });
|
||||
console.log(JSON.stringify({ counts, value: msg.value, active: document.activeElement === msg }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
""").replace("RECORDED_QUERY", json.dumps(recorded_query))
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 0, "focus": 1, "send": 0}
|
||||
assert payload["value"] == "hello world"
|
||||
assert payload["active"] is True
|
||||
|
||||
|
||||
def test_empty_or_absent_prefill_does_not_focus_composer():
|
||||
source = _node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
const counts = { focus: 0, send: 0 };
|
||||
const msg = { value: 'unchanged', focus() { counts.focus++; } };
|
||||
global.document = { getElementById(id) { return id === 'msg' ? msg : null; } };
|
||||
global.$ = (id) => document.getElementById(id);
|
||||
global.send = async () => { counts.send++; };
|
||||
await _applyComposerPrefillOnBoot({ hasText: false, text: ' ' });
|
||||
await _applyComposerPrefillOnBoot(null);
|
||||
console.log(JSON.stringify({ counts, value: msg.value }));
|
||||
})().catch(err => { console.error(err); process.exit(1); });
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["counts"] == {"autoResize": 1, "updateSendBtn": 1, "send": 0}
|
||||
assert payload["value"] == "second pass"
|
||||
assert payload == {"counts": {"focus": 0, "send": 0}, "value": "unchanged"}
|
||||
|
||||
|
||||
def test_non_empty_prefill_without_focus_method_still_updates_composer():
|
||||
source = _node_prelude() + """
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, send: 0 };
|
||||
const msg = { value: '' };
|
||||
global.document = {
|
||||
activeElement: null,
|
||||
getElementById(id) {
|
||||
return id === 'msg' ? msg : null;
|
||||
}
|
||||
};
|
||||
global.$ = (id) => document.getElementById(id);
|
||||
global.autoResize = () => { counts.autoResize++; };
|
||||
global.updateSendBtn = () => { counts.updateSendBtn++; };
|
||||
global.send = async () => { counts.send++; };
|
||||
await _applyComposerPrefillOnBoot({ hasText: true, text: 'hello world', autoSend: false });
|
||||
console.log(JSON.stringify({ counts, value: msg.value, active: document.activeElement === msg }));
|
||||
})().catch(err => { console.error(err); process.exit(1); });
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload == {
|
||||
"counts": {"autoResize": 1, "updateSendBtn": 0, "send": 0},
|
||||
"value": "hello world",
|
||||
"active": False,
|
||||
}
|
||||
|
||||
|
||||
def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready():
|
||||
@@ -216,8 +270,9 @@ def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready():
|
||||
(async () => {
|
||||
evalBoot('_applyComposerPrefillOnBoot');
|
||||
evalBoot('_finalizeComposerPrefillOnBoot');
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, consume: 0, send: 0 };
|
||||
const msg = { value: '' };
|
||||
const counts = { autoResize: 0, updateSendBtn: 0, consume: 0, focus: 0, send: 0 };
|
||||
const events = [];
|
||||
const msg = { value: '', focus() { counts.focus++; events.push('focus'); document.activeElement = this; } };
|
||||
global.document = {
|
||||
getElementById(id) {
|
||||
return id === 'msg' ? msg : null;
|
||||
@@ -227,11 +282,11 @@ def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready():
|
||||
global.autoResize = () => { counts.autoResize++; };
|
||||
global.updateSendBtn = () => { counts.updateSendBtn++; };
|
||||
global.send = async () => { counts.send++; };
|
||||
global._consumeComposerPrefillParamsFromLocation = () => { counts.consume++; };
|
||||
global._consumeComposerPrefillParamsFromLocation = () => { counts.consume++; events.push('consume'); };
|
||||
await _finalizeComposerPrefillOnBoot({ hasParams: true, hasText: true, text: 'hello world', autoSend: false });
|
||||
delete global.autoResize;
|
||||
await _finalizeComposerPrefillOnBoot({ hasParams: false, hasText: true, text: 'second pass', autoSend: false });
|
||||
console.log(JSON.stringify({ counts, value: msg.value }));
|
||||
console.log(JSON.stringify({ counts, events, value: msg.value }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
@@ -242,8 +297,10 @@ def test_terminal_prefill_step_consumes_params_after_boot_state_is_ready():
|
||||
"autoResize": 1,
|
||||
"updateSendBtn": 1,
|
||||
"consume": 1,
|
||||
"focus": 2,
|
||||
"send": 0,
|
||||
}
|
||||
assert payload["events"] == ["consume", "focus", "focus"]
|
||||
assert payload["value"] == "second pass"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user