Merge pull request #5405 from nesquena/gate-rebase/5162-steer-v3

Release v0.51.823: #5162 default message mode rename + steer default (#5145)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-01 22:54:32 -07:00
commit d096b5f5d9
18 files changed
+442 -236

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Changed
- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145)
### Fixed
- **The sidebar recovers on its own after a brief server blip during a profile switch.** If the backend returned a transient 502/503/504 (e.g. an nginx→backend restart window) on a warm session-list refresh — a profile switch, tab focus, or reconnect — the sidebar gave up on the first try and sat on the previous profile's list until you hard-reloaded. The idempotent session-list fetch now retries those transient upstream statuses on every refresh (not just the first cold load), so it heals itself without a Ctrl+F5. Thanks @weidzhou for the report and root-cause. (#5394)
+14 -2
View File
@@ -8060,7 +8060,7 @@ _SETTINGS_DEFAULTS = {
"dashboard_plugins": {}, # plugin_name -> bool, opt-in per plugin (default off per PF-10b)
"sidebar_density": "compact", # compact | detailed
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
"default_message_mode": "steer", # behavior when sending while agent is running: queue | interrupt | steer
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
"auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk
"provider_cost_budget": None,
@@ -8171,6 +8171,12 @@ def load_settings() -> dict:
if k not in _SETTINGS_LEGACY_DROP_KEYS
}
)
if (
"default_message_mode" not in stored
and "busy_input_mode" in stored
):
settings["default_message_mode"] = stored.get("busy_input_mode")
settings.pop("busy_input_mode", None)
# Grandfather established installs OFF for show_cli_sessions (#3988).
# The default flipped True so NEW users see CLI/TUI/messaging
# sessions without hunting for the toggle — but an existing user
@@ -8229,7 +8235,7 @@ _SETTINGS_ENUM_VALUES = {
"sidebar_density": {"compact", "detailed"},
"font_size": {"small", "default", "large", "xlarge"},
"auto_title_refresh_every": {"0", "5", "10", "20"},
"busy_input_mode": {"queue", "interrupt", "steer"},
"default_message_mode": {"queue", "interrupt", "steer"},
"chat_activity_display_mode": {"compact_worklog", "transparent_stream"},
"structured_code_default_view": {"auto", "on", "off"},
}
@@ -8323,6 +8329,12 @@ def save_settings(settings: dict) -> dict:
"activity_feed_expanded_default"
)
settings.pop("activity_feed_expanded_default", None)
if (
"default_message_mode" not in settings
and "busy_input_mode" in settings
):
settings["default_message_mode"] = settings.get("busy_input_mode")
settings.pop("busy_input_mode", None)
settings.pop("simplified_tool_calling", None)
pending_theme = current.get("theme")
pending_skin = current.get("skin")
+1 -1
View File
@@ -100,7 +100,7 @@ of Stop-and-send.
## Settings Model
Current Settings expose `Busy input mode` with three choices:
Current Settings expose `Default message mode` with three choices:
- Queue follow-up
- Interrupt current turn
+37 -24
View File
@@ -1064,34 +1064,43 @@ function _micToastKeyForRecognitionError(error){
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
// ── Busy input mode eager default (#5167) ───────────────────────────────────
// The Busy input mode preference (queue/interrupt/steer) is read on the send
// path via `window._busyInputMode||'queue'`. The authoritative value only
// arrives once the async boot IIFE below resolves the `/api/settings` fetch.
// Without an eager value, every send during that boot window silently falls
// back to 'queue', ignoring a saved 'steer'/'interrupt' preference (worse on
// ── Default message mode eager default (#5167 / #5145) ──────────────────────
// The Default message mode preference (queue/interrupt/steer) is read on the
// send path via `window._defaultMessageMode||'steer'`. The authoritative value
// only arrives once the async boot IIFE below resolves the `/api/settings`
// fetch. Without an eager value, every send during that boot window silently
// falls back, ignoring a saved 'queue'/'interrupt' preference (worse on
// slow/contended environments like WSL2, see #5132). Mirror the resolved value
// into localStorage — the same synchronous-source pattern used by hermes-lang /
// hermes-theme — so the very first send after a reload honors the saved choice.
const _BUSY_INPUT_MODES=['queue','interrupt','steer'];
function _normalizeBusyInputMode(mode){
return _BUSY_INPUT_MODES.includes(mode)?mode:'queue';
const _DEFAULT_MESSAGE_MODES=['queue','interrupt','steer'];
// Legacy localStorage key (pre-#5145 rename); read it as a fallback so an
// existing user's persisted busy-input-mode preference survives the rename.
const _LEGACY_DEFAULT_MESSAGE_MODE_KEY='hermes-busy-input-mode';
const _DEFAULT_MESSAGE_MODE_KEY='hermes-default-message-mode';
function _normalizeDefaultMessageMode(mode){
return _DEFAULT_MESSAGE_MODES.includes(mode)?mode:'steer';
}
function _persistBusyInputMode(mode){
const m=_normalizeBusyInputMode(mode);
try{localStorage.setItem('hermes-busy-input-mode',m);}catch(_){}
function _persistDefaultMessageMode(mode){
const m=_normalizeDefaultMessageMode(mode);
try{localStorage.setItem(_DEFAULT_MESSAGE_MODE_KEY,m);}catch(_){}
return m;
}
function _readPersistedBusyInputMode(){
function _readPersistedDefaultMessageMode(){
let stored=null;
try{stored=localStorage.getItem('hermes-busy-input-mode');}catch(_){}
return _normalizeBusyInputMode(stored);
try{
// Prefer the new key; fall back to the legacy key so a pre-rename
// preference is honored until the next explicit save rewrites the new key.
stored=localStorage.getItem(_DEFAULT_MESSAGE_MODE_KEY);
if(stored===null||stored===undefined) stored=localStorage.getItem(_LEGACY_DEFAULT_MESSAGE_MODE_KEY);
}catch(_){}
return _normalizeDefaultMessageMode(stored);
}
window._persistBusyInputMode=_persistBusyInputMode;
window._readPersistedBusyInputMode=_readPersistedBusyInputMode;
window._persistDefaultMessageMode=_persistDefaultMessageMode;
window._readPersistedDefaultMessageMode=_readPersistedDefaultMessageMode;
// Eager default set BEFORE the async settings fetch resolves so first sends in
// the boot window honor the persisted preference instead of defaulting to queue.
window._busyInputMode=_readPersistedBusyInputMode();
// the boot window honor the persisted preference instead of the raw default.
window._defaultMessageMode=_readPersistedDefaultMessageMode();
// ── Extension TTS-engine registry (registerHermesTtsEngine) ──────────────────
// Defined at MODULE scope (not inside the voice-mode IIFE below) so the public
@@ -2764,7 +2773,11 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
};
window._busyInputMode=_persistBusyInputMode(s.busy_input_mode);
// #5162 rename + steer default, layered on the #5170 localStorage mirror:
// resolve the mode (new key, legacy busy_input_mode fallback, else 'steer'
// via _normalizeDefaultMessageMode) and persist it so the very first send
// after a reload honors the saved choice.
window._defaultMessageMode=_persistDefaultMessageMode(s.default_message_mode||s.busy_input_mode);
window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint;
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._autoScrollFollow=s.auto_scroll_follow!==false;
@@ -2890,12 +2903,12 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility;
window._structuredCodeAutoTreeLines=10;
window._sidebarDensity='compact';
window._pinnedSessionsLimit=3;
// Settings load failed: keep the persisted busy-input-mode preference (the
// eager default already read it from the localStorage mirror) instead of
// clobbering it with 'queue', so a saved 'steer'/'interrupt' still applies
// Settings load failed: keep the persisted default-message-mode preference
// (the eager default already read it from the localStorage mirror) instead
// of clobbering it, so a saved 'steer'/'interrupt'/'queue' still applies
// when the server is unreachable (#5167). The placeholder-hint has no
// persisted mirror, so it defaults off on failure.
window._busyInputMode=_readPersistedBusyInputMode();
window._defaultMessageMode=_readPersistedDefaultMessageMode();
window._showBusyPlaceholderHint=false;
window._sessionEndlessScrollEnabled=false;
window._autoScrollFollow=true;
+3 -3
View File
@@ -1273,12 +1273,12 @@ async function cmdGoal(args){
}
// ── Busy-input mode commands ──────────────────────────────────────────────
// These commands let users override the default busy_input_mode setting for a
// These commands let users override the default message mode setting for a
// specific message. They are only meaningful while the agent is running.
/**
* /queue <message> — Explicitly queue a message for the next turn.
* Works regardless of the busy_input_mode setting.
* Works regardless of the default message mode setting.
*/
async function cmdQueue(args){
const msg=(args||'').trim();
@@ -1403,7 +1403,7 @@ function _showSteerRecovery(msg, explicitSteer, fallback) {
}
/**
* Shared implementation for /steer and the busy_input_mode='steer' path.
* Shared implementation for /steer and the default_message_mode='steer' path.
*
* Tries the real steer endpoint first. On any non-accept response (no cached
* agent, agent lacks steer, stream dead, etc.) it restores the draft and keeps
+70 -70
View File
@@ -309,17 +309,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrupted — sending new message',
settings_label_busy_input_mode: 'Busy input mode',
settings_desc_busy_input_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer injects a correction mid-turn without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.',
settings_label_default_message_mode: 'Default message mode',
settings_desc_default_message_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer injects a correction mid-turn without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.',
settings_label_fade_text_effect: 'Fade text effect',
settings_desc_fade_text_effect: 'Fade newly streamed words in while the assistant is responding. Similar to OpenWebUI; off by default for maximum performance.',
settings_label_max_tokens: 'Max output tokens',
settings_desc_max_tokens: 'Caps assistant output for new streaming turns. Leave blank to use the active profile config fallback.',
settings_placeholder_max_tokens_none: 'No override',
settings_placeholder_max_tokens_fallback: 'No override (fallback: {0})',
settings_busy_input_mode_queue: 'Queue follow-up',
settings_busy_input_mode_interrupt: 'Interrupt current turn',
settings_busy_input_mode_steer: 'Steer (mid-turn correction)',
settings_default_message_mode_queue: 'Queue follow-up',
settings_default_message_mode_interrupt: 'Interrupt current turn',
settings_default_message_mode_steer: 'Steer (mid-turn correction)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -1978,17 +1978,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrotto — invio nuovo messaggio',
settings_label_busy_input_mode: 'Modalità input occupato',
settings_desc_busy_input_mode: "Controlla cosa succede quando invii un messaggio mentre l'agente è in esecuzione. Coda attende; Interrompi annulla e ricomincia; Steer inietta una correzione a metà turno senza interrompere (fallback a coda se agente o stream non disponibili).",
settings_label_default_message_mode: 'Modalità messaggio predefinita',
settings_desc_default_message_mode: "Controlla cosa succede quando invii un messaggio mentre l'agente è in esecuzione. Coda attende; Interrompi annulla e ricomincia; Steer inietta una correzione a metà turno senza interrompere. Se Steer non è disponibile, la bozza viene ripristinata così puoi scegliere l'azione successiva.",
settings_label_fade_text_effect: 'Effetto dissolvenza testo',
settings_desc_fade_text_effect: 'Dissolve gradualmente le nuove parole trasmesse in streaming mentre l’assistente risponde. Simile a OpenWebUI; disattivato per impostazione predefinita per massime prestazioni.',
settings_label_max_tokens: 'Token massimi in uscita',
settings_desc_max_tokens: 'Limita la lunghezza delle nuove risposte in streaming. Lascia vuoto per usare il valore di fallback del profilo attivo.',
settings_placeholder_max_tokens_none: 'Nessun override',
settings_placeholder_max_tokens_fallback: 'Nessun override (fallback: {0})',
settings_busy_input_mode_queue: 'Accoda follow-up',
settings_busy_input_mode_interrupt: 'Interrompi turno corrente',
settings_busy_input_mode_steer: 'Steer (correzione a metà turno)',
settings_default_message_mode_queue: 'Accoda follow-up',
settings_default_message_mode_interrupt: 'Interrompi turno corrente',
settings_default_message_mode_steer: 'Steer (correzione a metà turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -3638,17 +3638,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: '中断 — 新しいメッセージを送信中',
settings_label_busy_input_mode: 'ビジー時の入力モード',
settings_desc_busy_input_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します (エージェントやストリームが利用不可ならキューにフォールバック)。',
settings_label_default_message_mode: '既定のメッセージモード',
settings_desc_default_message_mode: 'エージェント実行中にメッセージを送信した時の動作を制御します。Queue は待機、Interrupt はキャンセルして再開、Steer は中断せずにターン中に修正を注入します。Steer が利用できない場合は、下書きが復元されるため、次の操作を選べます。',
settings_label_fade_text_effect: 'テキストのフェード効果',
settings_desc_fade_text_effect: 'アシスタントの応答中に新しくストリーミングされた単語をフェードインします。OpenWebUI に似た表示です。最大パフォーマンスのため既定ではオフです。',
settings_label_max_tokens: '最大出力トークン数',
settings_desc_max_tokens: '新しいストリーミング応答の出力量を制限します。空欄のままにすると、現在のプロファイルのフォールバック値を使います。',
settings_placeholder_max_tokens_none: '上書きなし',
settings_placeholder_max_tokens_fallback: '上書きなし(フォールバック: {0})',
settings_busy_input_mode_queue: 'フォローアップをキュー',
settings_busy_input_mode_interrupt: '現在のターンを中断',
settings_busy_input_mode_steer: 'ステア (ターン中の修正)',
settings_default_message_mode_queue: 'フォローアップをキュー',
settings_default_message_mode_interrupt: '現在のターンを中断',
settings_default_message_mode_steer: 'ステア (ターン中の修正)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -5249,17 +5249,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Прервано — отправка нового сообщения',
settings_label_busy_input_mode: 'Режим ввода при занятости',
settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания.',
settings_label_default_message_mode: 'Режим сообщений по умолчанию',
settings_desc_default_message_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания. Если Steer недоступен, черновик восстанавливается, чтобы вы могли выбрать следующее действие.',
settings_label_fade_text_effect: 'Эффект плавного появления текста',
settings_desc_fade_text_effect: 'Плавно показывает новые слова во время ответа ассистента. Похоже на OpenWebUI; по умолчанию выключено для максимальной производительности.',
settings_label_max_tokens: 'Максимум выходных токенов',
settings_desc_max_tokens: 'Ограничивает длину новых потоковых ответов. Оставьте пустым, чтобы использовать резервное значение текущего профиля.',
settings_placeholder_max_tokens_none: 'Переопределения нет',
settings_placeholder_max_tokens_fallback: 'Переопределения нет (резерв: {0})',
settings_busy_input_mode_queue: 'Поставить в очередь',
settings_busy_input_mode_interrupt: 'Прервать текущий оборот',
settings_busy_input_mode_steer: 'Steer (прерывание + отправка)',
settings_default_message_mode_queue: 'Поставить в очередь',
settings_default_message_mode_interrupt: 'Прервать текущий оборот',
settings_default_message_mode_steer: 'Steer (коррекция в середине хода)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -6896,17 +6896,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje',
settings_label_busy_input_mode: 'Modo de entrada ocupada',
settings_desc_busy_input_mode: 'Controla qué sucede al enviar mensajes mientras el agente está activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer inyecta una corrección sin interrumpir (usa cola si el agente no está disponible).',
settings_label_default_message_mode: 'Modo de mensaje predeterminado',
settings_desc_default_message_mode: 'Controla qué sucede al enviar mensajes mientras el agente está activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer inyecta una corrección sin interrumpir. Si Steer no está disponible, el borrador se restaura para que puedas elegir la siguiente acción.',
settings_label_fade_text_effect: 'Efecto de desvanecimiento de texto',
settings_desc_fade_text_effect: 'Hace aparecer gradualmente las palabras nuevas mientras el asistente responde. Similar a OpenWebUI; desactivado por defecto para máximo rendimiento.',
settings_label_max_tokens: 'Máximo de tokens de salida',
settings_desc_max_tokens: 'Limita la longitud de las nuevas respuestas en streaming. Déjalo en blanco para usar el valor de respaldo del perfil activo.',
settings_placeholder_max_tokens_none: 'Sin reemplazo',
settings_placeholder_max_tokens_fallback: 'Sin reemplazo (respaldo: {0})',
settings_busy_input_mode_queue: 'Poner en cola',
settings_busy_input_mode_interrupt: 'Interrumpir turno actual',
settings_busy_input_mode_steer: 'Steer (corrección a mitad de turno)',
settings_default_message_mode_queue: 'Poner en cola',
settings_default_message_mode_interrupt: 'Interrumpir turno actual',
settings_default_message_mode_steer: 'Steer (corrección a mitad de turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -8464,17 +8464,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet',
settings_label_busy_input_mode: 'Eingabemodus bei Besch\u00e4ftigung',
settings_desc_busy_input_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer f\u00fcgt eine Korrektur ein ohne zu unterbrechen.',
settings_label_default_message_mode: 'Standard-Nachrichtenmodus',
settings_desc_default_message_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer f\u00fcgt eine Korrektur ein ohne zu unterbrechen. Wenn Steer nicht verf\u00fcgbar ist, wird der Entwurf wiederhergestellt, damit Sie die n\u00e4chste Aktion w\u00e4hlen k\u00f6nnen.',
settings_label_fade_text_effect: 'Text-Fade-Effekt',
settings_desc_fade_text_effect: 'Blendet neu gestreamte Wörter während der Antwort des Assistenten sanft ein. Ähnlich wie OpenWebUI; für maximale Leistung standardmäßig deaktiviert.',
settings_label_max_tokens: 'Maximale Ausgabetokens',
settings_desc_max_tokens: 'Begrenzt die Länge neuer Streaming-Antworten. Leer lassen, um den Fallback-Wert des aktiven Profils zu verwenden.',
settings_placeholder_max_tokens_none: 'Keine Überschreibung',
settings_placeholder_max_tokens_fallback: 'Keine Überschreibung (Fallback: {0})',
settings_busy_input_mode_queue: 'In Warteschlange einreihen',
settings_busy_input_mode_interrupt: 'Aktuellen Durchgang unterbrechen',
settings_busy_input_mode_steer: 'Steer (Korrektur ohne Unterbrechung)',
settings_default_message_mode_queue: 'In Warteschlange einreihen',
settings_default_message_mode_interrupt: 'Aktuellen Durchgang unterbrechen',
settings_default_message_mode_steer: 'Steer (Korrektur ohne Unterbrechung)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -10091,17 +10091,17 @@ const LOCALES = {
steer_recovery_retry: '重试',
steer_recovery_dismiss: '忽略',
busy_interrupt_confirm: '已中断 — 正在发送新消息',
settings_label_busy_input_mode: '忙碌输入模式',
settings_desc_busy_input_mode: '控制当代理正在运行时你发送消息会发生什么。队列会等待;中断会取消并重新开始;引导会在不中断的情况下注入中间修正(当流不可用时回退到队列)。',
settings_label_default_message_mode: '默认消息模式',
settings_desc_default_message_mode: '控制当代理正在运行时你发送消息会发生什么。队列会等待;中断会取消并重新开始;引导会在不中断的情况下注入中间修正。如果引导不可用,将恢复草稿,方便你选择下一步操作。',
settings_label_fade_text_effect: '淡入文字效果',
settings_desc_fade_text_effect: '在助手回复时淡入新流式单词。类似于 OpenWebUI;默认关闭以获得最佳性能。',
settings_label_max_tokens: '最大输出令牌数',
settings_desc_max_tokens: '限制新的流式响应可生成的输出量。留空时使用当前配置文件中的回退值。',
settings_placeholder_max_tokens_none: '无覆盖',
settings_placeholder_max_tokens_fallback: '无覆盖(回退值:{0})',
settings_busy_input_mode_queue: '队列后续消息',
settings_busy_input_mode_interrupt: '中断当前轮次',
settings_busy_input_mode_steer: '引导(中间修正)',
settings_default_message_mode_queue: '队列后续消息',
settings_default_message_mode_interrupt: '中断当前轮次',
settings_default_message_mode_steer: '引导(中间修正)',
settings_label_busy_placeholder_hint: '显示忙碌占位提示',
settings_desc_busy_placeholder_hint: '在会话运行且草稿为空时,显示带有当前忙碌操作的编辑器占位符。',
composer_placeholder_busy_queue: 'Enter = 队列 | /interrupt | /background | /steer',
@@ -11695,17 +11695,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: '已中斷,正在傳送新訊息',
settings_label_busy_input_mode: '忙碌輸入模式',
settings_desc_busy_input_mode: '控制 Agent 執行時送出訊息的行為。佇列會等待;中斷會取消並重新開始;Steer 會中途注入修正且不中斷(Agent 或串流無法使用時會退回佇列)。',
settings_label_default_message_mode: '預設訊息模式',
settings_desc_default_message_mode: '控制 Agent 執行時送出訊息的行為。佇列會等待;中斷會取消並重新開始;Steer 會中途注入修正且不中斷。如果 Steer 不可用,會恢復草稿,方便你選擇下一步操作。',
settings_label_fade_text_effect: '文字淡入效果',
settings_desc_fade_text_effect: '在助理回覆時讓新串流輸出的詞語淡入顯示。類似 OpenWebUI;為獲得最佳效能預設關閉。',
settings_label_max_tokens: '最大輸出權杖數',
settings_desc_max_tokens: '限制新的串流回覆可產生的輸出量。留空時使用目前設定檔中的回退值。',
settings_placeholder_max_tokens_none: '無覆寫',
settings_placeholder_max_tokens_fallback: '無覆寫(回退值:{0})',
settings_busy_input_mode_queue: '加入佇列',
settings_busy_input_mode_interrupt: '中斷目前回合',
settings_busy_input_mode_steer: 'Steer(中途修正)',
settings_default_message_mode_queue: '加入佇列',
settings_default_message_mode_interrupt: '中斷目前回合',
settings_default_message_mode_steer: 'Steer(中途修正)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -13284,17 +13284,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrompido — enviando nova mensagem',
settings_label_busy_input_mode: 'Modo de input ocupado',
settings_desc_busy_input_mode: 'Controla o que acontece ao enviar mensagem com agente rodando. Fila espera; Interromper cancela; Steer injeta correção.',
settings_label_default_message_mode: 'Modo de mensagem padrão',
settings_desc_default_message_mode: 'Controla o que acontece ao enviar mensagem com agente rodando. Fila espera; Interromper cancela e recomeça; Steer injeta correção no meio do turno sem interromper. Se Steer não estiver disponível, o rascunho é restaurado para você escolher a próxima ação.',
settings_label_fade_text_effect: 'Efeito de fade no texto',
settings_desc_fade_text_effect: 'Faz novas palavras aparecerem gradualmente enquanto o assistente responde. Similar ao OpenWebUI; desativado por padrão para melhor desempenho.',
settings_label_max_tokens: 'Máximo de tokens de saída',
settings_desc_max_tokens: 'Limita o tamanho das novas respostas em streaming. Deixe em branco para usar o valor de fallback do perfil ativo.',
settings_placeholder_max_tokens_none: 'Sem substituição',
settings_placeholder_max_tokens_fallback: 'Sem substituição (fallback: {0})',
settings_busy_input_mode_queue: 'Enfileirar follow-up',
settings_busy_input_mode_interrupt: 'Interromper turno atual',
settings_busy_input_mode_steer: 'Steer (correção no meio do turno)',
settings_default_message_mode_queue: 'Enfileirar follow-up',
settings_default_message_mode_interrupt: 'Interromper turno atual',
settings_default_message_mode_steer: 'Steer (correção no meio do turno)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -14827,17 +14827,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrupted — sending new message',
settings_label_busy_input_mode: '작업 중 입력 방식',
settings_desc_busy_input_mode: '에이전트가 실행 중일 때 메시지를 보내면 어떻게 처리할지 제어합니다. 대기는 다음 차례까지 기다리고, 중단은 현재 작업을 취소하고 새로 시작하며, 조정은 현재 작업을 중단하지 않고 중간 수정 사항을 전달합니다(에이전트 또는 스트림을 사용할 수 없으면 대기로 전환).',
settings_label_default_message_mode: '기본 메시지 모드',
settings_desc_default_message_mode: '에이전트가 실행 중일 때 메시지를 보내면 어떻게 처리할지 제어합니다. 대기는 다음 차례까지 기다리고, 중단은 현재 작업을 취소하고 새로 시작하며, 조정은 현재 작업을 중단하지 않고 중간 수정 사항을 전달합니다. 조정을 사용할 수 없으면 초안이 복원되어 다음 동작을 선택할 수 있습니다.',
settings_label_fade_text_effect: '텍스트 페이드 효과',
settings_desc_fade_text_effect: '어시스턴트가 응답하는 동안 새로 스트리밍되는 단어를 부드럽게 표시합니다. OpenWebUI와 비슷하며, 최대 성능을 위해 기본값은 꺼짐입니다.',
settings_label_max_tokens: '최대 출력 토큰',
settings_desc_max_tokens: '새 스트리밍 응답의 출력 길이를 제한합니다. 비워 두면 현재 프로필의 대체 값을 사용합니다.',
settings_placeholder_max_tokens_none: '재정의 없음',
settings_placeholder_max_tokens_fallback: '재정의 없음(대체값: {0})',
settings_busy_input_mode_queue: '후속 메시지 대기',
settings_busy_input_mode_interrupt: '현재 작업 중단',
settings_busy_input_mode_steer: '조정(중간 수정)',
settings_default_message_mode_queue: '후속 메시지 대기',
settings_default_message_mode_interrupt: '현재 작업 중단',
settings_default_message_mode_steer: '조정(중간 수정)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -16479,17 +16479,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Interrompu — envoi d\'un nouveau message',
settings_label_busy_input_mode: 'Mode de saisie lorsque occupé',
settings_desc_busy_input_mode: 'Contrôle ce qui se passe lorsque vous envoyez un message pendant que l\'agent est en cours d\'exécution. File d\'attente attend ; Interruption annule et recommence ; Steer injecte une correction en milieu de tour sans interrompre. Si Steer est indisponible, le brouillon est restauré pour que vous puissiez choisir la prochaine action.',
settings_label_default_message_mode: 'Mode de message par défaut',
settings_desc_default_message_mode: 'Contrôle ce qui se passe lorsque vous envoyez un message pendant que l\'agent est en cours d\'exécution. File d\'attente attend ; Interruption annule et recommence ; Steer injecte une correction en milieu de tour sans interrompre. Si Steer est indisponible, le brouillon est restauré pour que vous puissiez choisir la prochaine action.',
settings_label_fade_text_effect: 'Effet de fondu du texte',
settings_desc_fade_text_effect: 'Affiche progressivement les nouveaux mots diffusés pendant que l’assistant répond. Similaire à OpenWebUI ; désactivé par défaut pour des performances maximales.',
settings_label_max_tokens: 'Nombre maximal de jetons en sortie',
settings_desc_max_tokens: 'Limite la longueur des nouvelles réponses en streaming. Laissez vide pour utiliser la valeur de repli du profil actif.',
settings_placeholder_max_tokens_none: 'Aucune surcharge',
settings_placeholder_max_tokens_fallback: 'Aucune surcharge (repli : {0})',
settings_busy_input_mode_queue: 'Suivi de file d\'attente',
settings_busy_input_mode_interrupt: 'Interrompre le tour en cours',
settings_busy_input_mode_steer: 'Steer (correction en milieu de tour)',
settings_default_message_mode_queue: 'Suivi de file d\'attente',
settings_default_message_mode_interrupt: 'Interrompre le tour en cours',
settings_default_message_mode_steer: 'Steer (correction en milieu de tour)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -18129,17 +18129,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir',
settings_label_busy_input_mode: 'Meşgul giriş modu',
settings_desc_busy_input_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder (aracı veya akış mevcut olmadığında kuyruğa geri döner).',
settings_label_default_message_mode: 'Varsayılan mesaj modu',
settings_desc_default_message_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder. Steer kullanılamıyorsa taslak geri yüklenir, böylece sonraki adımı seçebilirsiniz.',
settings_label_fade_text_effect: 'Metin efektini soldur',
settings_desc_fade_text_effect: 'Asistan yanıt verirken yeni aktarılan sözcüklerin geçişini sağlayın. OpenWebUI\'ye benzer; Maksimum performans için varsayılan olarak kapalıdır.',
settings_label_max_tokens: 'Maksimum çıktı belirteci',
settings_desc_max_tokens: 'Yeni akış yanıtlarının uzunluğunu sınırlar. Etkin profilin yedek değerini kullanmak için boş bırakın.',
settings_placeholder_max_tokens_none: 'Geçersiz kılma yok',
settings_placeholder_max_tokens_fallback: 'Geçersiz kılma yok (yedek: {0})',
settings_busy_input_mode_queue: 'Sıra takibi',
settings_busy_input_mode_interrupt: 'Mevcut dönüşü kes',
settings_busy_input_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)',
settings_default_message_mode_queue: 'Sıra takibi',
settings_default_message_mode_interrupt: 'Mevcut dönüşü kes',
settings_default_message_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -19775,17 +19775,17 @@ const LOCALES = {
steer_recovery_retry: 'Spróbuj ponownie',
steer_recovery_dismiss: 'Odrzuć',
busy_interrupt_confirm: 'Przerwano — wysyłanie nowej wiadomości',
settings_label_busy_input_mode: 'Tryb wprowadzania przy zajętości',
settings_desc_busy_input_mode: 'Kontroluje, co dzieje się, gdy wysyłasz wiadomość, podczas gdy agent pracuje. Kolejkowanie czeka; Przerwanie anuluje i zaczyna od nowa; Kierowanie (Steer) wprowadza korektę w środku tury bez przerywania (cofa się do kolejkowania, gdy agent lub strumień jest niedostępny).',
settings_label_default_message_mode: 'Domyślny tryb wiadomości',
settings_desc_default_message_mode: 'Kontroluje, co dzieje się, gdy wysyłasz wiadomość, podczas gdy agent pracuje. Kolejkowanie czeka; Przerwanie anuluje i zaczyna od nowa; Kierowanie (Steer) wprowadza korektę w środku tury bez przerywania. Jeśli Steer jest niedostępny, szkic zostaje przywrócony, aby można było wybrać następne działanie.',
settings_label_fade_text_effect: 'Efekt pojawiania się tekstu',
settings_desc_fade_text_effect: 'Płynne pojawianie się nowo przesyłanych słów, podczas gdy asystent odpowiada. Podobnie jak w OpenWebUI; domyślnie wyłączone dla maksymalnej wydajności.',
settings_label_max_tokens: 'Maksymalna liczba tokenów wyjściowych',
settings_desc_max_tokens: 'Ogranicza długość nowych odpowiedzi strumieniowanych. Pozostaw puste, aby użyć wartości zapasowej z bieżącego profilu.',
settings_placeholder_max_tokens_none: 'Brak nadpisania',
settings_placeholder_max_tokens_fallback: 'Brak nadpisania (wartość zapasowa: {0})',
settings_busy_input_mode_queue: 'Kolejkuj tury',
settings_busy_input_mode_interrupt: 'Przerwij bieżącą turę',
settings_busy_input_mode_steer: 'Korekta w trakcie tury (steer)',
settings_default_message_mode_queue: 'Kolejkuj tury',
settings_default_message_mode_interrupt: 'Przerwij bieżącą turę',
settings_default_message_mode_steer: 'Korekta w trakcie tury (steer)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
@@ -21407,17 +21407,17 @@ const LOCALES = {
steer_recovery_retry: 'Retry',
steer_recovery_dismiss: 'Dismiss',
busy_interrupt_confirm: 'Đã ngắt — đang gửi tin nhắn mới',
settings_label_busy_input_mode: 'Chế độ nhập khi bận',
settings_desc_busy_input_mode: 'Điều khiển hành vi khi bạn gửi tin nhắn trong lúc agent đang chạy. Queue sẽ chờ; Interrupt sẽ hủy và bắt đầu mới; Steer sẽ chèn điều chỉnh giữa lượt mà không ngắt (sẽ fallback sang queue nếu agent hoặc stream không khả dụng).',
settings_label_default_message_mode: 'Chế độ tin nhắn mặc định',
settings_desc_default_message_mode: 'Điều khiển hành vi khi bạn gửi tin nhắn trong lúc agent đang chạy. Queue sẽ chờ; Interrupt sẽ hủy và bắt đầu mới; Steer sẽ chèn điều chỉnh giữa lượt mà không ngắt. Nếu Steer không khả dụng, bản nháp sẽ được khôi phục để bạn chọn hành động tiếp theo.',
settings_label_fade_text_effect: 'Hiệu ứng mờ chữ',
settings_desc_fade_text_effect: 'Làm mờ dần các từ mới stream vào khi assistant đang phản hồi. Tương tự OpenWebUI; mặc định tắt để đạt hiệu năng tối đa.',
settings_label_max_tokens: 'Số token đầu ra tối đa',
settings_desc_max_tokens: 'Giới hạn độ dài của các phản hồi stream mới. Để trống để dùng giá trị dự phòng của hồ sơ hiện tại.',
settings_placeholder_max_tokens_none: 'Không ghi đè',
settings_placeholder_max_tokens_fallback: 'Không ghi đè (dự phòng: {0})',
settings_busy_input_mode_queue: 'Xếp hàng phản hồi tiếp theo',
settings_busy_input_mode_interrupt: 'Ngắt lượt hiện tại',
settings_busy_input_mode_steer: 'Steer (điều chỉnh giữa lượt)',
settings_default_message_mode_queue: 'Xếp hàng phản hồi tiếp theo',
settings_default_message_mode_interrupt: 'Ngắt lượt hiện tại',
settings_default_message_mode_steer: 'Steer (điều chỉnh giữa lượt)',
settings_label_busy_placeholder_hint: 'Show busy placeholder hint',
settings_desc_busy_placeholder_hint: 'Shows the composer placeholder with the current busy action while the session is running and the draft is empty.',
composer_placeholder_busy_queue: 'Enter = queue | /interrupt | /background | /steer',
+7 -7
View File
@@ -1320,14 +1320,14 @@
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_auto_title_refresh">Automatically re-generates the session title based on the latest exchange, keeping it relevant as the conversation evolves. Requires an LLM title generation model to be configured.</div>
</div>
<div class="settings-field" data-settings-search="message default while running queue interrupt steer">
<label for="settingsBusyInputMode" data-i18n="settings_label_busy_input_mode">Busy input mode</label>
<select id="settingsBusyInputMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="queue" data-i18n="settings_busy_input_mode_queue">Queue follow-up</option>
<option value="interrupt" data-i18n="settings_busy_input_mode_interrupt">Interrupt current turn</option>
<option value="steer" data-i18n="settings_busy_input_mode_steer">Steer (mid-turn correction)</option>
<div class="settings-field" data-settings-search="busy input mode message default while running queue interrupt steer">
<label for="settingsDefaultMessageMode" data-i18n="settings_label_default_message_mode">Default message mode</label>
<select id="settingsDefaultMessageMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="queue" data-i18n="settings_default_message_mode_queue">Queue follow-up</option>
<option value="interrupt" data-i18n="settings_default_message_mode_interrupt">Interrupt current turn</option>
<option value="steer" data-i18n="settings_default_message_mode_steer">Steer (mid-turn correction)</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_busy_input_mode">Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_default_message_mode">Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting. If Steer is unavailable, the draft is restored so you can choose the next action.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
+7 -7
View File
@@ -1217,9 +1217,9 @@ function applySessionTitleUpdate(sid, titleText, options={}){
}
async function send(){
// Static guards expect _busyInputMode to stay near send() while the actual
// Static guards expect _defaultMessageMode to stay near send() while the actual
// read remains in the S.busy branch below.
// _busyInputMode
// _defaultMessageMode
// Reject concurrent invocations early — before any await yields control.
// If a send is already in-flight (e.g. queue drain), re-queue the message
// instead of silently dropping it.
@@ -1255,12 +1255,12 @@ async function send(){
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
_clearStaleBusyStateBeforeSend({compressionRunning});
// If busy or a manual compression is still running, handle based on busy_input_mode
// If busy or a manual compression is still running, handle based on default_message_mode
if(S.busy||compressionRunning){
if(text){
if(!S.session){await newSession();await renderSessionList();}
// Busy-control slash commands must be intercepted HERE, before the
// busyMode routing block, so the user can always type /steer, /interrupt,
// defaultMessageMode routing block, so the user can always type /steer, /interrupt,
// /queue, /terminal, /goal, or /yolo while the agent is running and have
// them execute immediately.
// Without this intercept they fall through to the queue and execute after
@@ -1277,8 +1277,8 @@ async function send(){
}
}
}
const busyMode=window._busyInputMode||'queue';
if(busyMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){
const defaultMessageMode=window._defaultMessageMode||'steer';
if(defaultMessageMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){
// Real steer: clear the input first so the user gets immediate
// feedback, then ship the steer payload via /api/chat/steer.
// _trySteer restores the draft and leaves the active stream running if
@@ -1290,7 +1290,7 @@ async function send(){
// After a delivered steer, clear any staged files because the text-only
// steer payload has been handled. On failure, keep files staged.
if(_steerDelivered){S.pendingFiles=[];renderTray();}
} else if(busyMode==='interrupt'){
} else if(defaultMessageMode==='interrupt'){
// Queue the message, then cancel so drain re-sends it.
const _modelState=_chatPayloadModelState();
queueSessionMessage(S.session.session_id,{text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
+22 -16
View File
@@ -7946,8 +7946,8 @@ function _preferencesPayloadFromUi(){
if(pinnedLimitField) payload.pinned_sessions_limit=parseInt(pinnedLimitField.value,10);
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
if(autoTitleRefreshSel) payload.auto_title_refresh_every=parseInt(autoTitleRefreshSel.value,10);
const busyInputModeSel=$('settingsBusyInputMode');
if(busyInputModeSel) payload.busy_input_mode=busyInputModeSel.value;
const defaultMessageModeSel=$('settingsDefaultMessageMode');
if(defaultMessageModeSel) payload.default_message_mode=defaultMessageModeSel.value;
const showBusyPlaceholderHintCb=$('settingsShowBusyPlaceholderHint');
if(showBusyPlaceholderHintCb) payload.show_busy_placeholder_hint=showBusyPlaceholderHintCb.checked;
const botNameField=$('settingsBotName');
@@ -8023,8 +8023,11 @@ async function _autosavePreferencesSettings(payload){
document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled';
if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference();
}
if(payload&&payload.busy_input_mode!==undefined){
window._busyInputMode=(typeof _persistBusyInputMode==='function')?_persistBusyInputMode(saved&&saved.busy_input_mode):((saved&&saved.busy_input_mode)||'queue');
if(payload&&payload.default_message_mode!==undefined){
// #5170 mirror write on autosave, under the #5145 rename: persist the
// saved mode so a reload/offline first-send honors it (legacy fallback).
const _dmm=(saved&&saved.default_message_mode)||(saved&&saved.busy_input_mode);
window._defaultMessageMode=(typeof _persistDefaultMessageMode==='function')?_persistDefaultMessageMode(_dmm):(_dmm||'steer');
if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder();
}
if(payload&&payload.show_busy_placeholder_hint!==undefined){
@@ -8620,13 +8623,14 @@ async function loadSettingsPanel(){
autoTitleRefreshSel.value=['0','5','10','20'].includes(val)?val:'0';
autoTitleRefreshSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
// Busy input mode
const busyInputModeSel=$('settingsBusyInputMode');
if(busyInputModeSel){
const val=String(settings.busy_input_mode||'queue');
busyInputModeSel.value=['queue','interrupt','steer'].includes(val)?val:'queue';
window._busyInputMode=(typeof _persistBusyInputMode==='function')?_persistBusyInputMode(busyInputModeSel.value):busyInputModeSel.value;
busyInputModeSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
// Default message mode
const defaultMessageModeSel=$('settingsDefaultMessageMode');
if(defaultMessageModeSel){
const val=String(settings.default_message_mode||settings.busy_input_mode||'steer');
defaultMessageModeSel.value=['queue','interrupt','steer'].includes(val)?val:'steer';
// #5170 mirror write on panel load, under the #5145 rename.
window._defaultMessageMode=(typeof _persistDefaultMessageMode==='function')?_persistDefaultMessageMode(defaultMessageModeSel.value):defaultMessageModeSel.value;
defaultMessageModeSel.addEventListener('change',_schedulePreferencesAutosave,{once:false});
}
const showBusyPlaceholderHintCb=$('settingsShowBusyPlaceholderHint');
if(showBusyPlaceholderHintCb){
@@ -10706,9 +10710,11 @@ function _applySavedSettingsUi(saved, body, opts){
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
window._busyInputMode=(typeof _persistBusyInputMode==='function')
? _persistBusyInputMode(body.busy_input_mode)
: (body.busy_input_mode||'queue');
// #5170 mirror write in _applySavedSettingsUi, under the #5145 rename:
// persist so a reload/offline first-send honors the resolved mode.
window._defaultMessageMode=(typeof _persistDefaultMessageMode==='function')
? _persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)
: (body.default_message_mode||body.busy_input_mode||'steer');
window._sessionEndlessScrollEnabled=!!body.session_endless_scroll;
window._autoScrollFollow=body.auto_scroll_follow!==false;
window._largeTextPasteAsAttachment=body.large_text_paste_as_attachment!==false;
@@ -11281,7 +11287,7 @@ async function saveSettings(andClose){
const fontSize=($('settingsFontSize')||{}).value||localStorage.getItem('hermes-font-size')||'default';
const language=($('settingsLanguage')||{}).value||'en';
const sidebarDensity=($('settingsSidebarDensity')||{}).value==='detailed'?'detailed':'compact';
const busyInputMode=($('settingsBusyInputMode')||{}).value||'queue';
const defaultMessageMode=($('settingsDefaultMessageMode')||{}).value||'steer';
const showBusyPlaceholderHint=!!($('settingsShowBusyPlaceholderHint')||{}).checked;
const body={};
@@ -11335,7 +11341,7 @@ async function saveSettings(andClose){
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
body.show_thinking=window._showThinking!==false;
body.sidebar_density=sidebarDensity;
body.busy_input_mode=busyInputMode;
body.default_message_mode=defaultMessageMode;
body.auto_title_refresh_every=(($('settingsAutoTitleRefresh')||{}).value||'0');
const botName=(($('settingsBotName')||{}).value||'').trim();
body.bot_name=botName||'Hermes';
+4 -4
View File
@@ -6402,12 +6402,12 @@ function getComposerPrimaryAction(){
}
const explicitAction=_getExplicitBusyCommandAction(msg&&msg.value);
if(explicitAction) return explicitAction;
const busyMode=window._busyInputMode||'queue';
if(busyMode==='steer'){
const defaultMessageMode=window._defaultMessageMode||'steer';
if(defaultMessageMode==='steer'){
if(S.activeStreamId&&typeof _trySteer==='function') return 'steer';
return 'queue';
}
if(busyMode==='interrupt'){
if(defaultMessageMode==='interrupt'){
if(S.activeStreamId&&typeof cancelStream==='function') return 'interrupt';
return 'queue';
}
@@ -6425,7 +6425,7 @@ function _applyBusyComposerPlaceholder(){
input.placeholder=idlePlaceholder;
return;
}
const busyMode=window._busyInputMode||'queue';
const busyMode=window._defaultMessageMode||'steer';
const busyPlaceholderKey=busyMode==='interrupt'
? 'composer_placeholder_busy_interrupt'
: busyMode==='steer'
+1 -1
View File
@@ -50,7 +50,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
("settingsNotificationsEnabled", "notifications_enabled"),
("settingsSidebarDensity", "sidebar_density"),
("settingsAutoTitleRefresh", "auto_title_refresh_every"),
("settingsBusyInputMode", "busy_input_mode"),
("settingsDefaultMessageMode", "default_message_mode"),
("settingsShowBusyPlaceholderHint", "show_busy_placeholder_hint"),
("settingsBotName", "bot_name"),
]
+46 -44
View File
@@ -1,10 +1,10 @@
"""Regression tests for busy_input_mode (PR #1062, closes #720).
"""Regression tests for default_message_mode (PR #1062, closes #720).
Pins the wiring for the three modes (queue / interrupt / steer):
- The setting key + default + enum validation in api/config.py
- Three slash commands registered in static/commands.js
- send()'s busy branch reads window._busyInputMode and dispatches
- Boot initializes window._busyInputMode from settings
- send()'s busy branch reads window._defaultMessageMode and dispatches
- Boot initializes window._defaultMessageMode from settings
- 17 new i18n keys present in all 6 locale blocks
Issue: #720 (configurable busy-input behaviour)
@@ -27,20 +27,20 @@ I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
class TestBusyInputModeSetting:
"""The new setting key must be registered with a default and enum validator."""
def test_default_is_queue(self):
"""Default value preserves existing queue behaviour for users who don't touch the setting."""
assert '"busy_input_mode": "queue"' in CONFIG_PY, (
"_DEFAULT_SETTINGS must include busy_input_mode='queue' so existing users see no change"
def test_default_is_steer(self):
"""Default value resolves to steer for users who don't touch the setting."""
assert '"default_message_mode": "steer"' in CONFIG_PY, (
"_DEFAULT_SETTINGS must include default_message_mode='steer' so new users see the steer default"
)
def test_enum_validator_present(self):
"""_SETTINGS_ENUM_KEYS must validate busy_input_mode against {queue, interrupt, steer}."""
"""_SETTINGS_ENUM_KEYS must validate default_message_mode against {queue, interrupt, steer}."""
# Find the entry inside the enum dict (a set literal as the value)
idx = CONFIG_PY.find('"busy_input_mode": {')
assert idx >= 0, "busy_input_mode entry missing from _SETTINGS_ENUM_KEYS"
idx = CONFIG_PY.find('"default_message_mode": {')
assert idx >= 0, "default_message_mode entry missing from _SETTINGS_ENUM_KEYS"
block = CONFIG_PY[idx:idx + 200]
assert '"queue"' in block and '"interrupt"' in block and '"steer"' in block, (
"busy_input_mode enum must contain {queue, interrupt, steer}"
"default_message_mode enum must contain {queue, interrupt, steer}"
)
@@ -164,7 +164,7 @@ class TestBusySendButton:
"the single primary button should remain visible while busy; it becomes Stop when there is no draft"
)
def test_composer_primary_action_accounts_for_all_busy_input_modes(self):
def test_composer_primary_action_accounts_for_all_default_message_modes(self):
idx = UI_JS.find("function getComposerPrimaryAction()")
assert idx >= 0, "getComposerPrimaryAction() not found"
body = UI_JS[idx:UI_JS.find("function _setComposerPrimaryButtonIcon", idx)]
@@ -172,9 +172,9 @@ class TestBusySendButton:
assert "return 'queue'" in body, "queue mode and unavailable steer/interrupt fallbacks must map to queue"
assert "return 'interrupt'" in body, "interrupt mode with an active stream must map to interrupt"
assert "return 'steer'" in body, "steer mode with active stream support must map to steer"
assert "window._busyInputMode||'queue'" in body, "helper must respect the Busy input mode setting"
assert "window._defaultMessageMode||'steer'" in body, "helper must respect the Default message mode setting"
assert "_getExplicitBusyCommandAction(msg&&msg.value)" in body, (
"explicit /queue, /interrupt, and /steer drafts must override the Busy input mode for button visuals"
"explicit /queue, /interrupt, and /steer drafts must override the Default message mode for button visuals"
)
def test_explicit_busy_commands_override_button_visual_action(self):
@@ -263,16 +263,16 @@ class TestBusySendButton:
class TestSendBusyBranchDispatch:
"""send()'s busy block must read window._busyInputMode and branch accordingly."""
"""send()'s busy block must read window._defaultMessageMode and branch accordingly."""
def test_send_reads_busy_input_mode(self):
# The send() function should read window._busyInputMode in the busy block
def test_send_reads_default_message_mode(self):
# The send() function should read window._defaultMessageMode in the busy block
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0
# Look in the first ~3000 chars of send() for the busy mode read
send_body = MESSAGES_JS[send_idx:send_idx + 3000]
assert "_busyInputMode" in send_body, (
"send() must read window._busyInputMode in the S.busy branch"
assert "_defaultMessageMode" in send_body, (
"send() must read window._defaultMessageMode in the S.busy branch"
)
def test_send_calls_cancel_stream_on_interrupt(self):
@@ -300,7 +300,7 @@ class TestSendBusyBranchDispatch:
"""
send_idx = MESSAGES_JS.find("async function send(")
assert send_idx >= 0, "send() not found"
steer_idx = MESSAGES_JS.find("busyMode==='steer'", send_idx)
steer_idx = MESSAGES_JS.find("defaultMessageMode==='steer'", send_idx)
assert steer_idx >= 0, "busy steer branch not found"
branch = MESSAGES_JS[steer_idx:steer_idx + 900]
assert "const _steerDelivered=await _trySteer" in branch
@@ -324,7 +324,7 @@ class TestSendBusyBranchDispatch:
assert busy_start >= 0, "busy block not found"
# The intercept must appear BEFORE the busyMode assignment
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue','terminal','goal','yolo'", busy_start)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
busymode_idx = MESSAGES_JS.find("_defaultMessageMode||'steer'", busy_start)
assert intercept_idx >= 0, (
"send() must intercept /steer /interrupt /queue /terminal /goal /yolo before the busyMode "
"routing block — otherwise they queue instead of executing immediately"
@@ -345,7 +345,7 @@ class TestSendBusyBranchDispatch:
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue','terminal','goal','yolo'", busy_start)
assert intercept_idx >= 0
# Get the intercept block (up to the next busyMode assignment)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
busymode_idx = MESSAGES_JS.find("_defaultMessageMode||'steer'", busy_start)
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
assert "_bc.fn(_pc.args)" in intercept_block, (
"The intercept must call the command handler directly via _bc.fn(_pc.args)"
@@ -365,7 +365,7 @@ class TestSendBusyBranchDispatch:
send_idx = MESSAGES_JS.find("async function send(")
busy_start = MESSAGES_JS.find("S.busy||compressionRunning", send_idx)
intercept_idx = MESSAGES_JS.find("'steer','interrupt','queue','terminal','goal','yolo'", busy_start)
busymode_idx = MESSAGES_JS.find("_busyInputMode||'queue'", busy_start)
busymode_idx = MESSAGES_JS.find("_defaultMessageMode||'steer'", busy_start)
intercept_block = MESSAGES_JS[intercept_idx:busymode_idx]
clear_idx = intercept_block.find("$('msg').value=''")
await_idx = intercept_block.find("await _bc.fn")
@@ -384,34 +384,36 @@ class TestSendBusyBranchDispatch:
class TestBootAndPanelsWiring:
def test_boot_init_default_path(self):
"""Boot success path initialises window._busyInputMode from settings.
"""Boot success path initialises window._defaultMessageMode from settings.
#5167: the assignment now routes through _persistBusyInputMode() so the
resolved value is mirrored into localStorage (the synchronous source the
eager default reads) while still being the single source of truth once
/api/settings resolves.
#5167/#5170: the assignment routes through _persistDefaultMessageMode()
so the resolved value is mirrored into localStorage (the synchronous
source the eager default reads) while still being the single source of
truth once /api/settings resolves. #5145 renamed the setting and reads
the legacy busy_input_mode key as a back-compat fallback.
"""
assert "window._busyInputMode=_persistBusyInputMode(s.busy_input_mode)" in BOOT_JS
assert "window._defaultMessageMode=_persistDefaultMessageMode(s.default_message_mode||s.busy_input_mode)" in BOOT_JS
def test_boot_init_fallback_path(self):
"""Boot fallback path (settings load failed) keeps the persisted preference.
#5167: instead of hard-clobbering to 'queue', the catch path re-reads the
persisted mirror so a saved 'steer'/'interrupt' still applies when the
server is unreachable. _readPersistedBusyInputMode() normalizes to 'queue'.
#5167/#5170: instead of hard-clobbering to a default, the catch path
re-reads the persisted mirror so a saved 'steer'/'interrupt'/'queue'
still applies when the server is unreachable. This must NOT regress to
a hardcoded default that ignores the saved choice.
"""
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS
def test_panels_load_save_apply(self):
assert "settingsBusyInputMode" in PANELS_JS, "panels.js must load the setting"
assert "body.busy_input_mode" in PANELS_JS, "saveSettings must include busy_input_mode in body"
assert "_persistBusyInputMode(body.busy_input_mode)" in PANELS_JS, (
"_applySavedSettingsUi must propagate busy_input_mode to the global "
"and persist it (so the next reload's eager default honors the save) (#5167)"
assert "settingsDefaultMessageMode" in PANELS_JS, "panels.js must load the setting"
assert "body.default_message_mode" in PANELS_JS, "saveSettings must include default_message_mode in body"
assert "_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)" in PANELS_JS, (
"_applySavedSettingsUi must propagate default_message_mode to the global "
"and persist it (so the next reload's eager default honors the save) (#5167/#5170)"
)
def test_index_html_dropdown_has_three_options(self):
idx = INDEX_HTML.find('id="settingsBusyInputMode"')
idx = INDEX_HTML.find('id="settingsDefaultMessageMode"')
assert idx >= 0
block = INDEX_HTML[idx:idx + 800]
assert 'value="queue"' in block
@@ -437,11 +439,11 @@ class TestI18nKeys:
"cmd_steer_fallback",
"busy_steer_fallback",
"busy_interrupt_confirm",
"settings_label_busy_input_mode",
"settings_desc_busy_input_mode",
"settings_busy_input_mode_queue",
"settings_busy_input_mode_interrupt",
"settings_busy_input_mode_steer",
"settings_label_default_message_mode",
"settings_desc_default_message_mode",
"settings_default_message_mode_queue",
"settings_default_message_mode_interrupt",
"settings_default_message_mode_steer",
]
def test_each_key_appears_at_least_six_times(self):
@@ -63,7 +63,7 @@ def test_busy_placeholder_helper_preserves_compression_and_drafts():
assert "if(_composerHasContent())" in helper_block
assert "assistantDisplayName()" in helper_block
assert "window._showBusyPlaceholderHint" in helper_block
assert "window._busyInputMode||'queue'" in helper_block
assert "window._defaultMessageMode||'steer'" in helper_block
assert "composer_placeholder_busy_queue" in helper_block
assert "composer_placeholder_busy_interrupt" in helper_block
assert "composer_placeholder_busy_steer" in helper_block
@@ -77,7 +77,7 @@ def test_busy_placeholder_helper_preserves_compression_and_drafts():
def test_locale_blocks_cover_new_keys():
locale_blocks = I18N_JS.count("settings_busy_input_mode_steer")
locale_blocks = I18N_JS.count("settings_default_message_mode_steer")
assert locale_blocks == 14
for key in [
"settings_label_busy_placeholder_hint",
+129
View File
@@ -0,0 +1,129 @@
"""Focused regression coverage for #5145 busy-input defaults."""
import re
from pathlib import Path
ROOT = Path(__file__).parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
LOCALE_KEYS = (
"en",
"it",
"ja",
"ru",
"es",
"de",
"zh",
"'zh-Hant'",
"pt",
"ko",
"fr",
"tr",
"pl",
"vi",
)
def _locale_block(locale_key):
escaped_keys = [re.escape(key) for key in LOCALE_KEYS]
next_keys = "|".join(key for key in escaped_keys if key != re.escape(locale_key))
pattern = rf"^ {re.escape(locale_key)}: \{{(?P<body>.*?)(?=^ (?:{next_keys}): \{{|\n\}};)"
match = re.search(pattern, I18N_JS, re.MULTILINE | re.DOTALL)
assert match, f"missing {locale_key} locale block"
return match.group("body")
def _default_message_mode_label(locale_key):
block = _locale_block(locale_key)
match = re.search(r"settings_label_default_message_mode: '([^']+)'", block)
assert match, f"missing default message mode label for {locale_key}"
return match.group(1)
def _default_message_mode_steer_option(locale_key):
block = _locale_block(locale_key)
match = re.search(r"settings_default_message_mode_steer: '([^']+)'", block)
assert match, f"missing default message mode steer option for {locale_key}"
return match.group(1)
def _default_message_mode_description(locale_key):
block = _locale_block(locale_key)
match = re.search(r"settings_desc_default_message_mode: ([\"'])((?:\\.|(?!\1).)*)\1", block)
assert match, f"missing default message mode description for {locale_key}"
return match.group(2)
def test_backend_default_resolves_to_steer():
assert '"default_message_mode": "steer"' in CONFIG_PY
def test_boot_defaults_resolve_to_steer():
# Success path routes through the #5170 persistence mirror; the resolved
# value still defaults to 'steer' because _normalizeDefaultMessageMode()
# returns 'steer' for a missing/invalid value.
assert "window._defaultMessageMode=_persistDefaultMessageMode(s.default_message_mode||s.busy_input_mode)" in BOOT_JS
assert "return _DEFAULT_MESSAGE_MODES.includes(mode)?mode:'steer';" in BOOT_JS
# Load-FAILURE path re-reads the persisted mirror (never a hardcoded literal),
# so a saved preference survives an unreachable server (#5167/#5170). With no
# persisted value the normalize fallback still yields 'steer'.
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS
assert "window._defaultMessageMode='steer'" not in BOOT_JS
def test_settings_panel_fallbacks_resolve_to_steer():
assert "String(settings.default_message_mode||settings.busy_input_mode||'steer')" in PANELS_JS
assert "['queue','interrupt','steer'].includes(val)?val:'steer'" in PANELS_JS
# _applySavedSettingsUi routes through the #5170 mirror; the ||'steer' tail
# keeps steer as the resolved default when the helper is unavailable.
assert "_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)" in PANELS_JS
assert "body.default_message_mode||body.busy_input_mode||'steer'" in PANELS_JS
assert "const defaultMessageMode=($('settingsDefaultMessageMode')||{}).value||'steer'" in PANELS_JS
def test_busy_input_label_changes_without_key_or_id_drift():
assert 'id="settingsDefaultMessageMode"' in INDEX_HTML
assert 'data-i18n="settings_label_default_message_mode">Default message mode' in INDEX_HTML
assert _default_message_mode_label("en") == "Default message mode"
assert I18N_JS.count("settings_label_default_message_mode: 'Default message mode'") == 1
def test_busy_input_labels_stay_in_their_locale_blocks():
assert _default_message_mode_label("it") == "Modalità messaggio predefinita"
assert _default_message_mode_label("ja") == "既定のメッセージモード"
assert _default_message_mode_label("ru") == "Режим сообщений по умолчанию"
assert _default_message_mode_label("es") == "Modo de mensaje predeterminado"
assert _default_message_mode_label("de") == "Standard-Nachrichtenmodus"
assert _default_message_mode_label("zh") == "默认消息模式"
assert _default_message_mode_label("'zh-Hant'") == "預設訊息模式"
assert _default_message_mode_label("pt") == "Modo de mensagem padrão"
assert _default_message_mode_label("ko") == "기본 메시지 모드"
assert _default_message_mode_label("fr") == "Mode de message par défaut"
assert _default_message_mode_label("tr") == "Varsayılan mesaj modu"
assert _default_message_mode_label("pl") == "Domyślny tryb wiadomości"
assert _default_message_mode_label("vi") == "Chế độ tin nhắn mặc định"
def test_russian_steer_option_keeps_mid_turn_meaning():
assert _default_message_mode_steer_option("ru") == "Steer (коррекция в середине хода)"
def test_localized_descriptions_keep_draft_restore_meaning():
expected_restore_phrases = {
"it": "bozza viene ripristinata",
"ja": "下書きが復元",
"ru": "черновик восстанавливается",
"es": "borrador se restaura",
"de": "Entwurf wiederhergestellt",
"zh": "恢复草稿",
"'zh-Hant'": "恢復草稿",
"pt": "rascunho é restaurado",
"ko": "초안이 복원",
"fr": "brouillon est restauré",
"tr": "taslak geri yüklenir",
"pl": "szkic zostaje przywrócony",
"vi": "bản nháp sẽ được khôi phục",
}
for locale_key, restore_phrase in expected_restore_phrases.items():
assert restore_phrase in _default_message_mode_description(locale_key), (
f"{locale_key} description must describe restoring the draft when Steer is unavailable"
)
@@ -10,11 +10,11 @@ class TestBusyInputSettingsSearchTerms:
def test_busy_input_field_has_supplemental_search_terms(self):
"""The busy-input field must expose the supplemental terms from HTML."""
assert 'data-settings-search="message default while running queue interrupt steer"' in INDEX_HTML, (
assert 'data-settings-search="busy input mode message default while running queue interrupt steer"' in INDEX_HTML, (
"busy-input field must expose supplemental search terms in data-settings-search"
)
assert "Busy input mode" in INDEX_HTML, (
"busy-input field label must still be present in the HTML"
assert 'data-i18n="settings_label_default_message_mode"' in INDEX_HTML, (
"busy-input field label must stay wired to the renamed i18n key in the HTML"
)
def test_settings_index_uses_field_text_for_busy_input(self):
+85 -45
View File
@@ -1,18 +1,23 @@
"""Regression tests for the busy_input_mode boot race (#5167).
"""Regression tests for the default-message-mode boot race (#5167 / #5170).
The Busy input mode preference (queue/interrupt/steer) was ignored for any send
that happened before the async boot IIFE resolved `await api('/api/settings')`,
because `window._busyInputMode` had NO eager default — it was `undefined` during
the boot window, so the send path's `window._busyInputMode||'queue'` silently
fell back to 'queue'. Re-saving from the settings panel set the global
The Default message mode preference (queue/interrupt/steer) was ignored for any
send that happened before the async boot IIFE resolved `await api('/api/settings')`,
because `window._defaultMessageMode` had NO eager default — it was `undefined`
during the boot window, so the send path's `window._defaultMessageMode||'steer'`
silently fell back. Re-saving from the settings panel set the global
synchronously, which is why "re-save fixes it".
The fix gives `window._busyInputMode` a deterministic value at script top, read
synchronously from a localStorage mirror ('hermes-busy-input-mode') that the
resolved settings + the save handler both keep in sync — mirroring how
hermes-lang / hermes-theme are bootstrapped from a synchronous source.
The fix gives `window._defaultMessageMode` a deterministic value at script top,
read synchronously from a localStorage mirror ('hermes-default-message-mode',
with a legacy 'hermes-busy-input-mode' fallback read) that the resolved settings
+ the save handler both keep in sync — mirroring how hermes-lang / hermes-theme
are bootstrapped from a synchronous source.
Reported by @b3nw. Issue: #5167.
Reported by @b3nw. Issues: #5167 (boot race), #5170 (autosave + panel-load
mirror). #5145 renamed the setting busy_input_mode -> default_message_mode and
flipped the default to 'steer'; this file was updated for the rename while
preserving the persistence-mirror guarantees (the load-failure path must still
honor the saved preference, not clobber it with a hardcoded default).
"""
from pathlib import Path
@@ -24,13 +29,13 @@ UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
class TestEagerDefault:
"""window._busyInputMode must be set eagerly, before the async settings fetch."""
"""window._defaultMessageMode must be set eagerly, before the async settings fetch."""
def test_eager_default_assigned_at_module_scope(self):
"""An eager top-level assignment must exist so first sends honor the preference."""
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS, (
"boot.js must eagerly initialise window._busyInputMode from the persisted "
"mirror at module scope so sends during the boot window don't default to queue"
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS, (
"boot.js must eagerly initialise window._defaultMessageMode from the persisted "
"mirror at module scope so sends during the boot window don't default silently"
)
def test_eager_default_precedes_async_settings_fetch(self):
@@ -39,13 +44,13 @@ class TestEagerDefault:
This is the whole point of the fix: the value must be deterministic during
the window between page load and the settings fetch resolving.
"""
eager_idx = BOOT_JS.find("window._busyInputMode=_readPersistedBusyInputMode()")
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
assert eager_idx >= 0, "eager default assignment not found"
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
await_idx = BOOT_JS.find("const s=await api('/api/settings')")
assert await_idx >= 0, "async settings fetch not found"
assert eager_idx < await_idx, (
"the eager window._busyInputMode default must be set BEFORE the async "
"the eager window._defaultMessageMode default must be set BEFORE the async "
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
)
@@ -53,7 +58,7 @@ class TestEagerDefault:
"""The eager default in boot.js loads after messages.js (defer order), but the
assignment itself must sit at top level so it runs during script evaluation,
not inside a later-firing callback."""
eager_idx = BOOT_JS.find("window._busyInputMode=_readPersistedBusyInputMode()")
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
# Must be at the start of a line (top-level statement), not indented inside a fn.
line_start = BOOT_JS.rfind("\n", 0, eager_idx) + 1
assert BOOT_JS[line_start:eager_idx].strip() == "", (
@@ -66,71 +71,106 @@ class TestSyncMirrorHelpers:
"""Helper functions backing the synchronous localStorage mirror."""
def test_persist_helper_defined_and_exposed(self):
assert "function _persistBusyInputMode(" in BOOT_JS
assert "window._persistBusyInputMode=_persistBusyInputMode" in BOOT_JS
assert "function _persistDefaultMessageMode(" in BOOT_JS
assert "window._persistDefaultMessageMode=_persistDefaultMessageMode" in BOOT_JS
def test_read_helper_defined_and_exposed(self):
assert "function _readPersistedBusyInputMode(" in BOOT_JS
assert "window._readPersistedBusyInputMode=_readPersistedBusyInputMode" in BOOT_JS
assert "function _readPersistedDefaultMessageMode(" in BOOT_JS
assert "window._readPersistedDefaultMessageMode=_readPersistedDefaultMessageMode" in BOOT_JS
def test_mirror_uses_dedicated_localstorage_key(self):
assert "localStorage.setItem('hermes-busy-input-mode'" in BOOT_JS, (
"persist helper must write the busy mode to a dedicated localStorage key"
assert "localStorage.setItem(_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
"persist helper must write the mode to the dedicated localStorage key"
)
assert "localStorage.getItem('hermes-busy-input-mode')" in BOOT_JS, (
"read helper must read the busy mode from the same localStorage key"
assert "localStorage.getItem(_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
"read helper must read the mode from the same localStorage key"
)
assert "_DEFAULT_MESSAGE_MODE_KEY='hermes-default-message-mode'" in BOOT_JS, (
"the dedicated key must be 'hermes-default-message-mode'"
)
def test_mirror_reads_legacy_key_as_fallback(self):
"""A pre-rename user's persisted preference (under the old
'hermes-busy-input-mode' key) must survive the rename via a fallback read."""
assert "_LEGACY_DEFAULT_MESSAGE_MODE_KEY='hermes-busy-input-mode'" in BOOT_JS, (
"the legacy localStorage key must still be recognised for back-compat"
)
assert "localStorage.getItem(_LEGACY_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
"the read helper must fall back to the legacy key so an existing "
"user's persisted preference survives the #5145 rename"
)
def test_normalize_validates_against_known_modes(self):
"""Unknown/garbage persisted values must normalize to 'queue', never crash."""
assert "function _normalizeBusyInputMode(" in BOOT_JS
"""Unknown/garbage persisted values must normalize to the safe default, never crash."""
assert "function _normalizeDefaultMessageMode(" in BOOT_JS
assert "['queue','interrupt','steer']" in BOOT_JS
def test_localstorage_access_is_guarded(self):
"""localStorage can throw (privacy mode / disabled). Helpers must try/catch."""
idx = BOOT_JS.find("function _persistBusyInputMode(")
idx = BOOT_JS.find("function _persistDefaultMessageMode(")
body = BOOT_JS[idx:idx + 400]
assert "try{" in body and "catch(_)" in body, (
"_persistBusyInputMode must guard localStorage access against exceptions"
"_persistDefaultMessageMode must guard localStorage access against exceptions"
)
idx = BOOT_JS.find("function _readPersistedBusyInputMode(")
body = BOOT_JS[idx:idx + 400]
idx = BOOT_JS.find("function _readPersistedDefaultMessageMode(")
body = BOOT_JS[idx:idx + 600]
assert "try{" in body and "catch(_)" in body, (
"_readPersistedBusyInputMode must guard localStorage access against exceptions"
"_readPersistedDefaultMessageMode must guard localStorage access against exceptions"
)
class TestAssignmentSitesSyncMirror:
"""Every place that sets window._busyInputMode must keep the mirror in sync."""
"""Every place that sets window._defaultMessageMode must keep the mirror in sync."""
def test_boot_success_path_persists(self):
assert "window._busyInputMode=_persistBusyInputMode(s.busy_input_mode)" in BOOT_JS
assert "window._defaultMessageMode=_persistDefaultMessageMode(s.default_message_mode||s.busy_input_mode)" in BOOT_JS
def test_boot_catch_path_reads_persisted(self):
assert "window._busyInputMode=_readPersistedBusyInputMode()" in BOOT_JS
"""The critical #5167/#5170 guarantee: settings-load FAILURE must re-read the
persisted preference, NOT clobber it with a hardcoded default. A saved
'steer'/'interrupt'/'queue' must still apply when the server is unreachable."""
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS
# Guard against regression: the catch path must NOT hardcode a literal mode.
assert "window._defaultMessageMode='steer'" not in BOOT_JS, (
"the settings-load-failure path must read the persisted preference, not "
"clobber it with a hardcoded 'steer' that ignores a saved 'interrupt'/'queue'"
)
def test_panels_save_persists(self):
assert "_persistBusyInputMode(body.busy_input_mode)" in PANELS_JS, (
"the settings save handler must persist busy_input_mode so the next "
assert "_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)" in PANELS_JS, (
"the settings save handler must persist default_message_mode so the next "
"reload's eager default reads the saved value"
)
def test_panels_save_guards_helper_availability(self):
"""panels.js loads before boot.js (defer order); the save handler must
guard the helper with typeof so a regression in load order can't throw."""
idx = PANELS_JS.find("_persistBusyInputMode(body.busy_input_mode)")
idx = PANELS_JS.find("_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)")
assert idx >= 0
window = PANELS_JS[max(0, idx - 120):idx]
assert "typeof _persistBusyInputMode==='function'" in window, (
"panels.js save handler must guard _persistBusyInputMode with a typeof check"
window = PANELS_JS[max(0, idx - 160):idx]
assert "typeof _persistDefaultMessageMode==='function'" in window, (
"panels.js save handler must guard _persistDefaultMessageMode with a typeof check"
)
def test_panels_autosave_persists(self):
"""#5170: the preferences autosave path must also persist the mode."""
assert "_persistDefaultMessageMode(_dmm)" in PANELS_JS, (
"the autosave handler must persist default_message_mode so a reload after "
"an autosave honors the saved value (#5170)"
)
def test_panels_panel_load_persists(self):
"""#5170: opening the settings panel must also mirror the mode into localStorage."""
assert "_persistDefaultMessageMode(defaultMessageModeSel.value)" in PANELS_JS, (
"loadSettingsPanel must persist default_message_mode on panel load (#5170)"
)
class TestReadSitesUnchanged:
"""The defensive read sites must keep their ||'queue' fallback (belt + braces)."""
"""The defensive read sites must keep their ||'steer' fallback (belt + braces)."""
def test_messages_read_site_intact(self):
assert "const busyMode=window._busyInputMode||'queue';" in MESSAGES_JS
assert "const defaultMessageMode=window._defaultMessageMode||'steer';" in MESSAGES_JS
def test_ui_read_site_intact(self):
assert "const busyMode=window._busyInputMode||'queue';" in UI_JS
assert "window._defaultMessageMode||'steer'" in UI_JS
+6 -6
View File
@@ -134,7 +134,7 @@ class TestYoloCommandRegistration:
@pytest.fixture(scope="class")
def commands_js(self):
with open("static/commands.js", "r") as f:
with open("static/commands.js", "r", encoding="utf-8") as f:
return f.read()
def test_yolo_command_in_array(self, commands_js):
@@ -155,7 +155,7 @@ class TestYoloBusySendPath:
@pytest.fixture(scope="class")
def messages_js(self):
with open("static/messages.js", "r") as f:
with open("static/messages.js", "r", encoding="utf-8") as f:
return f.read()
def test_yolo_in_busy_send_allowlist(self, messages_js):
@@ -166,7 +166,7 @@ class TestYoloBusySendPath:
intercept_start = messages_js.find("if(text.startsWith('/')", busy_start)
assert intercept_start >= 0, "busy slash intercept block not found in send()"
intercept_idx = messages_js.find("'steer','interrupt','queue','terminal','goal','yolo'", intercept_start)
busymode_idx = messages_js.find("_busyInputMode||'queue'", busy_start)
busymode_idx = messages_js.find("_defaultMessageMode||'steer'", busy_start)
assert intercept_idx >= 0, "Busy-path slash allowlist must include yolo in the mid-turn branch"
assert intercept_idx < busymode_idx, "Busy-path intercept must run before busyMode routing"
@@ -188,7 +188,7 @@ class TestYoloPillHTML:
@pytest.fixture(scope="class")
def index_html(self):
with open("static/index.html", "r") as f:
with open("static/index.html", "r", encoding="utf-8") as f:
return f.read()
def test_yolo_pill_element_exists(self, index_html):
@@ -211,7 +211,7 @@ class TestYoloCSS:
@pytest.fixture(scope="class")
def style_css(self):
with open("static/style.css", "r") as f:
with open("static/style.css", "r", encoding="utf-8") as f:
return f.read()
def test_yolo_pill_class(self, style_css):
@@ -242,7 +242,7 @@ class TestYoloI18n:
@pytest.fixture(scope="class")
def i18n_js(self):
with open("static/i18n.js", "r") as f:
with open("static/i18n.js", "r", encoding="utf-8") as f:
return f.read()
@pytest.mark.parametrize("locale", LOCALES)
+1 -1
View File
@@ -278,7 +278,7 @@ class TestFrontendWiring:
def test_send_busy_steer_uses_try_steer(self):
# send() in messages.js: when busyMode === 'steer', should call _trySteer
idx = self.msgs.find("busyMode==='steer'")
idx = self.msgs.find("defaultMessageMode==='steer'")
assert idx >= 0
block = self.msgs[idx:idx + 800]
assert "_trySteer" in block, "send()'s steer branch must delegate to _trySteer"