diff --git a/CHANGELOG.md b/CHANGELOG.md index de014156..142934e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Removed +- **Reverted the manual per-session status labels (Todo / In Progress / Done).** The feature added in v0.51.284 (#3570) stored the chosen status only in browser `localStorage`, keyed by session id, with no server-side backing — so labels silently did not persist across browsers or devices (a user who labeled sessions on one machine saw none of them after switching to a laptop). It also rendered the three statuses as flat top-level entries in the session context menu alongside Copy/Rename/Pin/etc., which crowded the root menu. Removed entirely for now (JS state + cycle logic, context-menu entries, sidebar badge render, CSS, and all locale strings); the feature can be reintroduced later with proper server-side persistence and a less intrusive menu treatment. (reverts #3570) + + ## [v0.51.294] — 2026-06-06 — Release JJ (stage-3401 — live-to-final assistant reply redesign) ### Changed diff --git a/api/routes.py b/api/routes.py index deac7e82..925247e6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1955,6 +1955,7 @@ def _resolve_compatible_session_model_state( *, profile_provider: str | None = None, profile_default_model: str | None = None, + explicit_model_pick: bool = False, ) -> tuple[str, str | None, bool]: """Return (effective_model, effective_provider, model_was_normalized). @@ -2020,6 +2021,9 @@ def _resolve_compatible_session_model_state( break if model_family and model_family != _profile_provider_normalized: + if explicit_model_pick: + # User explicitly chose a cross-family model; honor it (#3737) + return model, profile_provider, False _target = _profile_default or default_model return _target, profile_provider, True @@ -2117,6 +2121,9 @@ def _resolve_compatible_session_model_state( slash = model.find("/") if slash < 0: + if explicit_model_pick: + # User explicitly chose this model; don't second-guess (#3737) + return model, requested_provider, False model_lower = model.lower() for bare_prefix in ("gpt", "claude", "gemini"): if model_lower.startswith(bare_prefix): @@ -11408,12 +11415,14 @@ def _handle_chat_start(handler, body, diag=None): else getattr(s, "model_provider", None) ) _pp_provider, _pp_default = _read_profile_model_config(s, requested_provider) + explicit_model_pick = bool(body.get("explicit_model_pick")) diag.stage("resolve_model_provider") if diag else None model, model_provider, normalized_model = _resolve_compatible_session_model_state( requested_model, requested_provider, profile_provider=_pp_provider, profile_default_model=_pp_default, + explicit_model_pick=explicit_model_pick, ) from api.runtime_adapter import ( LegacyJournalRuntimeAdapter, diff --git a/static/boot.js b/static/boot.js index 01ec91d4..497e6cd6 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1202,12 +1202,11 @@ $('modelSelect').onchange=async()=>{ model:modelState.model, model_provider:modelState.model_provider||null, })}); - if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){ - const pending=_readPendingSessionModel(S.session.session_id); - if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){ - _clearPendingSessionModel(S.session.session_id); - } - } + // NOTE: do NOT clear the pending explicit-pick marker here. It must survive until + // the NEXT send() consumes it, otherwise the normal "pick → session-update → send" + // flow loses the explicit-pick signal before /api/chat/start runs and the server + // re-reverts a cross-family pick (the #3737 bug, Codex catch). send() clears it + // after reading a matching pending pick. (#3739/#3737) _applySessionContextMetadataUpdate(data); // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ diff --git a/static/i18n.js b/static/i18n.js index 9834848e..e0a2631a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -670,10 +670,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', kanban_links: 'Links', @@ -2014,10 +2010,6 @@ const LOCALES = { kanban_status_running: 'In esecuzione', kanban_status_blocked: 'Bloccato', kanban_status_done: 'Completato', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_comments_count: 'Commenti ({0})', kanban_events_count: 'Eventi ({0})', kanban_links: 'Link', @@ -3350,10 +3342,6 @@ const LOCALES = { kanban_status_running: '実行中', kanban_status_blocked: 'ブロック中', kanban_status_done: '完了', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: '実際のステータス: {0}。このダイアログはトリアージ/ToDo/準備完了の編集のみ対応しています。', kanban_comments_count: 'コメント ({0})', kanban_events_count: 'イベント ({0})', @@ -4478,10 +4466,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', @@ -5751,10 +5735,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', @@ -7008,10 +6988,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', @@ -8308,10 +8284,6 @@ const LOCALES = { kanban_status_running: '进行中', kanban_status_blocked: '阻塞', kanban_status_done: '完成', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: '实际状态:{0}。此对话框仅支持编辑 Triage/Todo/Ready。', kanban_comments_count: '评论 ({0})', kanban_events_count: '事件 ({0})', @@ -9779,10 +9751,6 @@ const LOCALES = { kanban_status_running: '執行中', kanban_status_blocked: '已封鎖', kanban_status_done: '完成', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_comments_count: '留言 ({0})', kanban_events_count: '事件 ({0})', kanban_links: '連結', @@ -10999,10 +10967,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', @@ -12235,10 +12199,6 @@ const LOCALES = { kanban_status_running: 'Running', kanban_status_blocked: 'Blocked', kanban_status_done: 'Done', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Actual status: {0}. This dialog only supports Triage/Todo/Ready edits.', kanban_comments_count: 'Comments ({0})', kanban_events_count: 'Events ({0})', @@ -13495,10 +13455,6 @@ const LOCALES = { kanban_status_running: 'En cours d\'exécution', kanban_status_blocked: 'Bloqué', kanban_status_done: 'Fait', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_comments_count: 'Commentaires ({0})', kanban_events_count: 'Événements ({0})', kanban_links: 'Links', @@ -14843,10 +14799,6 @@ const LOCALES = { kanban_status_running: 'Koşma', kanban_status_blocked: 'Engellendi', kanban_status_done: 'Tamamlamak', - session_status_todo: 'Todo', - session_status_in_progress: 'In Progress', - session_status_done: 'Done', - session_status_click_to_change: 'Click to cycle status', kanban_status_original_hint: 'Gerçek durum: {0}. Bu iletişim kutusu yalnızca Triyaj/Yapılacak/Hazır düzenlemelerini destekler.', kanban_comments_count: 'Yorumlar ({0})', kanban_events_count: 'Etkinlikler ({0})', diff --git a/static/messages.js b/static/messages.js index 520fd126..0c481b90 100644 --- a/static/messages.js +++ b/static/messages.js @@ -655,6 +655,18 @@ async function send(){ let streamId; try{ const _modelState=_chatPayloadModelState(); + const _pendingPick=(typeof _readPendingSessionModel==='function') + ? _readPendingSessionModel(activeSid) + : null; + const _explicitPick=_pendingPick + && _pendingPick.model===_modelState.model + && String(_pendingPick.model_provider||'')===String(_modelState.model_provider||''); + // Consume the pending explicit-pick marker for THIS send only. The marker is + // recorded on modelSelect.onchange and intentionally kept (not cleared on + // session-update) so it survives the normal pick→update→send flow; clear it here + // once read so a later send of an unchanged dropdown isn't treated as an explicit + // pick. (#3739/#3737, Codex catch) + if(_explicitPick && typeof _clearPendingSessionModel==='function') _clearPendingSessionModel(activeSid); const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({ session_id:activeSid,message:msgText, // S.session.model remains authoritative; the helper only resolves a @@ -662,12 +674,17 @@ async function send(){ model:_modelState.model,workspace:S.session.workspace, model_provider:_modelState.model_provider, profile:S.activeProfile||S.session.profile||'default', + explicit_model_pick:_explicitPick||undefined, attachments:uploaded.length?uploaded:undefined })}); if(startData.title) applySessionTitleUpdate(activeSid, startData.title, {provisionalText:displayText.slice(0,64), rememberProvisional:true}); if(startData.effective_model && S.session){ + const _sentModel=_modelState.model; + if(_explicitPick && _sentModel && startData.effective_model!==_sentModel && typeof showToast==='function'){ + showToast('Model '+_sentModel+' changed to '+startData.effective_model+' — profile provider mismatch', 5000); + } S.session.model=startData.effective_model; S.session.model_provider=startData.effective_model_provider||S.session.model_provider||null; localStorage.setItem('hermes-webui-model', startData.effective_model); diff --git a/static/sessions.js b/static/sessions.js index d202cc3d..cff71d20 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -170,7 +170,6 @@ function _clearComposerDraft(sid) { const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming'; -const SESSION_MANUAL_STATUS_KEY = 'hermes-session-manual-status'; let _sessionViewedCounts = null; let _sessionCompletionUnread = null; let _sessionObservedStreaming = null; @@ -354,43 +353,6 @@ function _isServerIdleSessionRow(s) { return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message); } -// ── Manual session status (Todo / In Progress / Done) ───────────────────── -const _SESSION_STATUS_VALUES = ['todo', 'in-progress', 'done']; - -function _getSessionManualStatuses() { - try { - const parsed = JSON.parse(localStorage.getItem(SESSION_MANUAL_STATUS_KEY) || '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch (_e) { return {}; } -} - -function getSessionManualStatus(sid) { - if (!sid) return null; - const val = _getSessionManualStatuses()[sid]; - return _SESSION_STATUS_VALUES.includes(val) ? val : null; -} - -function setSessionManualStatus(sid, status) { - if (!sid) return; - const map = _getSessionManualStatuses(); - if (status && _SESSION_STATUS_VALUES.includes(status)) { - map[sid] = status; - } else { - delete map[sid]; - } - try { localStorage.setItem(SESSION_MANUAL_STATUS_KEY, JSON.stringify(map)); } catch (_e) {} - renderSessionListFromCache(); -} - -function _cycleSessionManualStatus(session) { - const current = getSessionManualStatus(session.session_id); - const idx = _SESSION_STATUS_VALUES.indexOf(current); - const next = idx === -1 ? _SESSION_STATUS_VALUES[0] - : idx === _SESSION_STATUS_VALUES.length - 1 ? null - : _SESSION_STATUS_VALUES[idx + 1]; - setSessionManualStatus(session.session_id, next); -} - function _reconcileActiveSessionIdleStateFromList(serverRows) { if (!S || !S.session || !S.session.session_id) return false; if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false; @@ -3028,22 +2990,6 @@ function _openSessionActionMenu(session, anchorEl){ } )); } - // Manual status picker (before danger actions) - if (!isExternalSession) { - const currentStatus = getSessionManualStatus(session.session_id); - for (const status of _SESSION_STATUS_VALUES) { - menu.appendChild(_buildSessionAction( - t('session_status_' + status.replace(/-/g,'_')) || status, - '', - '', - () => { - closeSessionActionMenu(); - setSessionManualStatus(session.session_id, currentStatus === status ? null : status); - }, - currentStatus === status ? 'is-active' : '' - )); - } - } if(!isExternalSession){ if(session.worktree_path){ menu.appendChild(_buildSessionAction( @@ -5026,15 +4972,6 @@ function renderSessionListFromCache(){ titleRow.appendChild(dot); } } - const manualStatus = getSessionManualStatus(s.session_id); - if (manualStatus) { - const statusBadge = document.createElement('span'); - statusBadge.className = 'session-manual-status session-manual-status--' + manualStatus; - statusBadge.textContent = t('session_status_' + manualStatus.replace(/-/g,'_')) || manualStatus; - statusBadge.title = t('session_status_click_to_change') || 'Click to change status'; - statusBadge.onclick = (e) => { e.stopPropagation(); _cycleSessionManualStatus(s); }; - titleRow.appendChild(statusBadge); - } const density=(window._sidebarDensity==='detailed'?'detailed':'compact'); const showLineageMetadata=density==='detailed'; const lineageKey=_sidebarLineageKeyForRow(s); diff --git a/static/style.css b/static/style.css index 6cd765ee..dbea5a93 100644 --- a/static/style.css +++ b/static/style.css @@ -4109,13 +4109,6 @@ main.main > #mainPlugin{display:none;} the row's gap:6px handles spacing, no margin/vertical-align needed. */ .session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;} -/* Manual session status badge */ -.session-manual-status{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:9px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;flex-shrink:0;cursor:pointer;line-height:1.6;border:1px solid transparent;transition:opacity .12s;} -.session-manual-status:hover{opacity:.75;} -.session-manual-status--todo{background:rgba(99,179,237,.18);border-color:rgba(99,179,237,.35);color:#63b3ed;} -.session-manual-status--in-progress{background:rgba(246,173,85,.18);border-color:rgba(246,173,85,.35);color:#f6ad55;} -.session-manual-status--done{background:rgba(72,187,120,.18);border-color:rgba(72,187,120,.35);color:#48bb78;} - /* ── Code copy button ── */ .code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;} .code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);} diff --git a/tests/test_issue3737_explicit_pick_client_wiring.py b/tests/test_issue3737_explicit_pick_client_wiring.py new file mode 100644 index 00000000..9d445684 --- /dev/null +++ b/tests/test_issue3737_explicit_pick_client_wiring.py @@ -0,0 +1,75 @@ +"""Regression guard for the #3737 explicit-pick CLIENT clear-timing fix (PR #3739 + Codex catch). + +The server-side resolver coverage lives in test_provider_mismatch.py +(test_explicit_pick_survives_profile_family_mismatch / _false_allows_normalization). + +This file locks the CLIENT-side wiring that makes the flag actually engage in the +normal flow. Codex found that boot.js modelSelect.onchange cleared the pending +explicit-pick marker right after /api/session/update, so by the time send() ran the +marker was gone, _explicitPick was false, and the server reverted the cross-family +pick anyway — the flag only worked in a rare race. The fix: + + * onchange RECORDS the pick (_rememberPendingSessionModel) and must NOT clear it; + * send() consumes it (reads, then _clearPendingSessionModel) for that send only. + +These are static source-structure assertions (the flow is DOM/network-driven and +exercised live); they keep the clear-timing from silently regressing. +""" +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") + + +def _model_onchange_region() -> str: + """The modelSelect.onchange handler body where the pending pick is recorded and + the /api/session/update round-trip happens.""" + idx = BOOT_JS.find("_rememberPendingSessionModel") + assert idx != -1, "_rememberPendingSessionModel call not found in boot.js" + # Window from the remember call through the session-update POST + its aftermath. + return BOOT_JS[idx: idx + 1200] + + +def test_onchange_records_pending_pick(): + region = _model_onchange_region() + assert "_rememberPendingSessionModel(" in region, ( + "modelSelect.onchange must record the explicit pick so send() can detect it" + ) + + +def test_onchange_does_not_clear_pending_pick_after_session_update(): + """The premature clear (the #3737 bug) must be gone: onchange must NOT call + _clearPendingSessionModel after the /api/session/update POST.""" + region = _model_onchange_region() + assert "_clearPendingSessionModel" not in region, ( + "modelSelect.onchange must NOT clear the pending explicit-pick marker — it has " + "to survive until send() consumes it, else the normal pick→update→send flow " + "loses the explicit-pick signal and the server re-reverts the pick (#3737)" + ) + + +def test_send_consumes_pending_pick_after_reading_it(): + """send() must clear the marker once it has read a matching pending pick, so a + later send of an unchanged dropdown is not treated as a fresh explicit pick.""" + idx = MESSAGES_JS.find("_explicitPick") + assert idx != -1, "_explicitPick not computed in send()" + region = MESSAGES_JS[idx: idx + 1100] + assert "_clearPendingSessionModel(activeSid)" in region, ( + "send() must consume (clear) the pending explicit-pick marker after reading it" + ) + # The clear must be gated on _explicitPick (only consume a genuine, matching pick). + assert re.search(r"_explicitPick\s*&&[^\n]*_clearPendingSessionModel", region), ( + "the consume-clear must be gated on _explicitPick" + ) + + +def test_send_sends_flag_only_when_explicit(): + idx = MESSAGES_JS.find("_explicitPick") + region = MESSAGES_JS[idx: idx + 1300] + assert "explicit_model_pick:_explicitPick||undefined" in region.replace(" ", ""), ( + "the chat/start payload must send explicit_model_pick only when truthy" + ) diff --git a/tests/test_model_selection_refresh_persistence.py b/tests/test_model_selection_refresh_persistence.py index 3f3eaad3..cca70140 100644 --- a/tests/test_model_selection_refresh_persistence.py +++ b/tests/test_model_selection_refresh_persistence.py @@ -30,7 +30,14 @@ def test_model_selection_records_pending_state_before_async_session_update(): assert pending_idx < update_idx assert local_model_idx < update_idx - assert "_clearPendingSessionModel" in body + # onchange must NOT clear the pending marker after the session-update round-trip. + # The marker has to survive until the next send() consumes it, otherwise the normal + # pick→update→send flow loses the explicit-pick signal and the server re-reverts a + # cross-family pick (#3737). The consume-clear now lives in send(), not here. + assert "_clearPendingSessionModel" not in body, ( + "modelSelect.onchange must not clear the pending explicit-pick marker (#3737); " + "send() consumes it instead" + ) def test_load_session_applies_pending_model_before_first_topbar_sync(): diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index ca5c0d4d..2c1f2a29 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -1438,6 +1438,43 @@ def test_custom_namespace_model_always_preserved_on_custom_provider(monkeypatch) assert effective == "custom/my-local-llm" +def test_explicit_pick_survives_profile_family_mismatch(): + """When explicit_model_pick=True, a cross-family bare model survives + the profile-aware normalization instead of being rewritten to the + profile default (#3737).""" + import api.routes as routes + + effective, provider, changed = routes._resolve_compatible_session_model_state( + "gpt-5.4-mini", + None, + profile_provider="anthropic", + profile_default_model="claude-sonnet-4", + explicit_model_pick=True, + ) + + assert changed is False, "explicit pick must not be normalized" + assert effective == "gpt-5.4-mini", "user's model must survive" + assert provider == "anthropic", "profile provider context preserved" + + +def test_explicit_pick_false_allows_profile_family_normalization(): + """Without explicit_model_pick, the same cross-family model IS rewritten + to the profile default (existing behavior, must not regress).""" + import api.routes as routes + + effective, provider, changed = routes._resolve_compatible_session_model_state( + "gpt-5.4-mini", + None, + profile_provider="anthropic", + profile_default_model="claude-sonnet-4", + explicit_model_pick=False, + ) + + assert changed is True, "stale model must be normalized" + assert effective == "claude-sonnet-4", "rewritten to profile default" + assert provider == "anthropic", "profile provider context preserved" + + def test_stale_ui_js_does_not_inject_unavailable_option(): """renderSession() must no longer inject a bare (unavailable) option into modelSelect when the session model is not in the provider list (#829).