diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b83d529..ac4da7c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ ### Fixed +- **Opt-in busy composer placeholder hint.** A new default-off setting swaps the composer placeholder to a busy-mode-specific hint (queue / steer / interrupt) when the agent is running, deferring to the compression placeholder and any user draft, and restoring on idle. Text-only (no layout change), XSS-safe, localized across all 14 locales. Thanks @rodboev. (#5161, #5144) + +- **A server-initiated turn that finished during an SSE gap now renders correctly on a visible-tab wake.** When a tab regained focus after the SSE stream had a gap, a self-heal reload could clobber a concurrent same-session `server_turn_started`, losing live-render ownership. The active stream id is now re-read after the awaited message load and scoped to the same session, so a concurrent same-session stream is honored by the attach/idle decision. The original SSE-gap self-heal (metadata-only server-ahead, in-place replace, no jump) is intact. Thanks @allenliang2022. (#5248) +- **The latest assistant response now carries a single accessible landmark, only once it has settled.** The "Latest Hermes response" landmark is no longer applied to an actively-streaming turn (only the latest settled turn gets it), giving screen-reader users one stable, correctly-placed landmark. Thanks @sheldon-im. (#5207) + - **A just-settled Compact Worklog now stays open for an unpinned reader instead of jumping.** When a streaming turn settled, the worklog could collapse-jump for a reader who wasn't pinned to the bottom; the disarm path now collapse-renders consistently for both pinned and unpinned readers (one frame, height-stable swap), so there's no post-settle scroll jump on mobile. No cache leak, and no #4970 regression. Thanks @allenliang2022. (#5260) - **The sidebar source filter now offers Webhooks parity with Cron Jobs.** Adds a `show_webhook_sessions` toggle threaded through the session-list cache key + builder and a localized checkbox, so webhook-origin sessions can be filtered like cron sessions. Preserves the archived-row paging behavior. Thanks @MaudeBot. (#4957, closes #4956) diff --git a/api/background_process.py b/api/background_process.py index 9e31d530..cffaa224 100644 --- a/api/background_process.py +++ b/api/background_process.py @@ -288,6 +288,71 @@ def active_stream_id_for_session(session_id: str) -> Optional[str]: return None +def persisted_message_count_for_session(session_id: str) -> Optional[int]: + """Cheap, metadata-only persisted ``message_count`` for *session_id*, or None. + + Companion to ``active_stream_id_for_session`` for the per-session SSE + on-subscribe self-heal. ``active_stream_id_for_session`` recovers a turn + that is live RIGHT NOW (replay ``server_turn_started``). But a + SERVER-initiated turn (self-wake / cron / restart hook) can start AND + finish entirely inside an SSE gap: the fire-and-forget + ``server_turn_started`` reached no subscriber AND the run already cleared + from ``ACTIVE_RUNS`` by the time the tab reconnects, so the replay finds + nothing (returns None) and the tab's transcript stays stale until a hard + refresh — the reported visible-tab defect. To detect that case the handler + compares the freshly-(re)subscribed tab's last-known count against this + persisted count; a server that is AHEAD means a turn landed during the gap. + + Reads via ``metadata_only=True`` so it never parses the full transcript + (this runs on every per-session SSE (re)connect). The persisted count is + written by ``Session.save`` as ``meta['message_count'] = len(messages)`` — + the SAME basis the frontend's ``S.session.message_count`` is built from — + so the comparison is apples-to-apples. Returns None when the count is + unknown (legacy sidecars without a persisted count); the caller treats + None as "cannot tell, do nothing", never as a trigger. + """ + try: + from api.models import get_session + + s = get_session(session_id, metadata_only=True) + count = getattr(s, "_metadata_message_count", None) + if count is None: + msgs = getattr(s, "messages", None) + count = len(msgs) if isinstance(msgs, list) and msgs else None + return int(count) if count is not None else None + except Exception: + logger.debug( + "persisted_message_count_for_session lookup failed for %s", + session_id, + exc_info=True, + ) + return None + + +def should_emit_session_updated( + subscriber_known_count: Optional[int], + persisted_count: Optional[int], +) -> bool: + """Gate for the per-session SSE "finished during the gap" self-heal emit. + + Single source of truth shared by the SSE handler and its tests so the two + cannot drift (the handler MUST call this, not inline the comparison). + Emit a ``session-updated`` frame ONLY when: + * the (re)subscribing tab reported a last-known count (``?known_count``), + AND + * the persisted server-side count is known, AND + * the server is STRICTLY ahead (a turn landed during the gap). + A missing known count (tab didn't report), an unknown persisted count + (legacy sidecar), or an equal/behind count all return False — never a + spurious reload. + """ + if subscriber_known_count is None: + return False + if persisted_count is None: + return False + return persisted_count > subscriber_known_count + + def _reaper_loop() -> None: logger.info("SessionChannel reaper thread started") while not _REAPER_STOP.is_set(): diff --git a/api/config.py b/api/config.py index b22ec075..b5c2130e 100644 --- a/api/config.py +++ b/api/config.py @@ -7795,6 +7795,7 @@ _SETTINGS_DEFAULTS = { "show_token_usage": False, # show input/output token badge below assistant messages "show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media) "show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel + "show_busy_placeholder_hint": False, # opt-in busy composer placeholder hint "hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons "virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings). "virtualize_transcript_optin": False, # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration). @@ -8040,6 +8041,7 @@ _SETTINGS_BOOL_KEYS = { "show_token_usage", "show_quota_chip", "show_conversation_outline", + "show_busy_placeholder_hint", "hide_empty_state_suggestions", "virtualize_transcript", "virtualize_transcript_optin", diff --git a/api/routes.py b/api/routes.py index b0e84eac..a2724056 100644 --- a/api/routes.py +++ b/api/routes.py @@ -16187,9 +16187,22 @@ def _handle_session_sse_stream(handler, parsed): if not sid: return bad(handler, "session_id is required") + # The (re)subscribing tab reports its last-known message_count via + # ?known_count=N so the on-subscribe self-heal can detect a server-initiated + # turn that started AND finished entirely inside this tab's SSE gap (see the + # "server-initiated turn finished during the gap" self-heal block below). + # Absent/blank/non-numeric => None ("tab didn't report", never triggers). + _known_count_raw = parse_qs(parsed.query).get("known_count", [""])[0] + try: + subscriber_known_count = int(_known_count_raw) if _known_count_raw != "" else None + except (TypeError, ValueError): + subscriber_known_count = None + from api.background_process import ( subscribe_to_session_channel, active_stream_id_for_session, + persisted_message_count_for_session, + should_emit_session_updated, ) # Atomic get-or-create + subscribe under SESSION_CHANNELS_LOCK. Doing these @@ -16274,6 +16287,35 @@ def _handle_session_sse_stream(handler, parsed): "source": "subscribe_recovery", "recovered": True, }) + else: + # ── Server-initiated turn that FINISHED during the SSE gap ── + # The block above only heals a turn that is live RIGHT NOW. But + # a server-initiated turn (self-wake / cron / restart hook) can + # start AND finish entirely inside the gap: the fire-and-forget + # `server_turn_started` reached no subscriber, and by the time + # this tab reconnects the run has already cleared from + # ACTIVE_RUNS — so active_stream_id_for_session returns None and + # nothing above replays. The turn IS persisted, but this tab's + # transcript stays stale until a hard refresh (the reported + # visible-tab defect). Detect it by comparing the persisted + # message_count against what this (re)subscribing tab last knew + # (?known_count). If the server is AHEAD, emit a lightweight + # `session-updated` frame so the tab does an INCREMENTAL, + # swap-in-place message sync (frontend reuses #5189's + # keepStaleUntilLoaded loadSession path — NO clear+refetch, so + # the #5177/#5189 blank-gap jump is not reintroduced). Carries + # only counts (no transcript) to stay cheap. Skipped + # entirely when the tab didn't report a count or the persisted + # count is unknown (legacy sidecar) → never a spurious reload. + if subscriber_known_count is not None: + persisted_count = persisted_message_count_for_session(sid) + if should_emit_session_updated(subscriber_known_count, persisted_count): + _sse(handler, 'session-updated', { + "session_id": sid, + "message_count": persisted_count, + "known_count": subscriber_known_count, + "source": "subscribe_recovery", + }) except _CLIENT_DISCONNECT_ERRORS: # Client vanished mid-recovery — re-raise so the outer handler # treats it as a normal disconnect and the finally still cleans up. diff --git a/static/boot.js b/static/boot.js index 162dd786..064db995 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2321,6 +2321,7 @@ function applyBotName(){ if(topbarTitle && (!S.session)) topbarTitle.textContent=name; const msg=$('msg'); if(msg) msg.placeholder='Message '+name+'\u2026'; + if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder(); } const _COMPOSER_CONTROL_TOGGLE_DEFS=[ @@ -2454,6 +2455,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000, }; window._busyInputMode=(s.busy_input_mode||'queue'); + window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint; window._sessionEndlessScrollEnabled=!!s.session_endless_scroll; window._autoScrollFollow=s.auto_scroll_follow!==false; window._largeTextPasteAsAttachment=s.large_text_paste_as_attachment!==false; @@ -2576,6 +2578,7 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; window._sidebarDensity='compact'; window._pinnedSessionsLimit=3; window._busyInputMode='queue'; + window._showBusyPlaceholderHint=false; window._sessionEndlessScrollEnabled=false; window._autoScrollFollow=true; window._composerControlVisibility=_composerControlVisibilityFromSettings(null); diff --git a/static/i18n.js b/static/i18n.js index 4b92d80f..c884c5e5 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -320,6 +320,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge:'Skill', slash_skill_desc:'Invoke this skill', @@ -1963,6 +1968,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge:'Skill', slash_skill_desc:'Invoca questa skill', @@ -3597,6 +3607,11 @@ const LOCALES = { settings_busy_input_mode_queue: 'フォローアップをキュー', settings_busy_input_mode_interrupt: '現在のターンを中断', settings_busy_input_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge:'スキル', slash_skill_desc:'このスキルを呼び出す', @@ -5182,6 +5197,11 @@ const LOCALES = { settings_busy_input_mode_queue: 'Поставить в очередь', settings_busy_input_mode_interrupt: 'Прервать текущий оборот', settings_busy_input_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)', clarify_heading: 'Требуется уточнение', @@ -6803,6 +6823,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)', available_personalities: 'Personalidades disponibles:', personality_switch_hint: '\n\nUsa `/personality ` para cambiar, o `/personality none` para limpiar.', @@ -8345,6 +8370,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)', available_personalities: 'Verfügbare Persönlichkeiten:', personality_switch_hint: '\n\nNutze `/personality ` zum Wechseln, oder `/personality none` zum Löschen.', @@ -9946,6 +9976,11 @@ const LOCALES = { settings_busy_input_mode_queue: '队列后续消息', settings_busy_input_mode_interrupt: '中断当前轮次', settings_busy_input_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', settings_plugins_title: '插件', plugins_enable_toggle: '启用', settings_plugins_meta: '查看已安装的 Hermes 插件及其注册的生命周期挂钩。此面板为只读。', @@ -11524,6 +11559,11 @@ const LOCALES = { settings_busy_input_mode_queue: '加入佇列', settings_busy_input_mode_interrupt: '中斷目前回合', settings_busy_input_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: '技能', slash_skill_desc: '叫用此技能', @@ -13087,6 +13127,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: 'Skill', slash_skill_desc: 'Invocar esta skill', @@ -14604,6 +14649,11 @@ const LOCALES = { settings_busy_input_mode_queue: '후속 메시지 대기', settings_busy_input_mode_interrupt: '현재 작업 중단', settings_busy_input_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: '스킬', slash_skill_desc: '이 스킬 실행', @@ -16230,6 +16280,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: 'Compétence', slash_skill_desc: 'Invoquer cette compétence', @@ -17854,6 +17909,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: 'Yetenek', slash_skill_desc: 'Bu beceriyi çağır', @@ -19474,6 +19534,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge: 'Umiejętność', slash_skill_desc: 'Wywołaj tę umiejętność', @@ -21080,6 +21145,11 @@ const LOCALES = { 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_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', + composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer', + composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt', slash_skill_badge:'Kỹ năng', slash_skill_desc:'Gọi kỹ năng này', diff --git a/static/index.html b/static/index.html index 200081c3..249e0cd5 100644 --- a/static/index.html +++ b/static/index.html @@ -1315,6 +1315,13 @@
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.
+
+ +
Shows the composer placeholder with the current busy action while the session is running and the draft is empty.
+