From 2d1b4642e26eb02ca7add49c0ec3e06532765a4e Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 21:20:42 +0000 Subject: [PATCH 01/10] feat: syntax highlighting in workspace file preview (#3337, @mysoul12138) --- static/style.css | 1 + static/workspace.js | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/static/style.css b/static/style.css index d755b435..80911d8a 100644 --- a/static/style.css +++ b/static/style.css @@ -1957,6 +1957,7 @@ .preview-area.visible{display:flex;opacity:1;} .preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;} .preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--pre-text);} + .preview-code code[class*="language-"]{background:var(--code-bg) !important;} /* Image preview */ .preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;} .preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);} diff --git a/static/workspace.js b/static/workspace.js index 5cc5378d..6c911ca9 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -573,6 +573,32 @@ function cancelEditMode(){ updateEditBtn(); } +// Map file extensions to Prism.js language identifiers. +// Prism autoloader fetches missing language components from CDN on demand. +const _PRISM_LANG_MAP={ + js:'javascript',mjs:'javascript',jsx:'jsx',ts:'typescript',tsx:'tsx', + py:'python',pyw:'python',pyi:'python', + rb:'ruby',go:'go',rs:'rust',java:'java',kt:'kotlin',kts:'kotlin', + c:'c',h:'c',cpp:'cpp',cxx:'cpp',hpp:'cpp',cc:'cpp', + cs:'csharp',swift:'swift',scala:'scala', + php:'php',pl:'perl',pm:'perl',r:'r',lua:'lua', + sh:'bash',bash:'bash',zsh:'bash',fish:'bash', + ps1:'powershell',psm1:'powershell', + sql:'sql',graphql:'graphql', + json:'json',yaml:'yaml',yml:'yaml',toml:'toml',xml:'xml', + html:'markup',htm:'markup',svg:'markup',vue:'markup', + css:'css',scss:'scss',sass:'sass',less:'less', + md:'markdown',markdown:'markdown', + dockerfile:'docker',makefile:'makefile',cmake:'cmake', + ini:'ini',cfg:'ini',conf:'ini',properties:'properties', + diff:'diff',patch:'diff', + txt:'',log:'',csv:'',tsv:'', +}; +function _prismLanguageForPath(path){ + const ext=fileExt(path).replace(/^\./,''); + return _PRISM_LANG_MAP[ext]!==undefined?_PRISM_LANG_MAP[ext]:'plaintext'; +} + async function openFile(path, opts={}){ if(!S.session)return; const ext=fileExt(path); @@ -659,7 +685,17 @@ async function openFile(path, opts={}){ return; } showPreview('code'); - $('previewCode').textContent=data.content; + // Syntax highlighting with Prism.js (already loaded on the page). + const codeEl=document.createElement('code'); + codeEl.textContent=data.content; + const lang=_prismLanguageForPath(path); + if(lang) codeEl.className='language-'+lang; + const pre=$('previewCode'); + pre.textContent=''; + pre.appendChild(codeEl); + if(typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){ + Prism.highlightElement(codeEl); + } }catch(e){ // If it's a 400/too-large error, offer download instead downloadFile(path); From 949dc7aac8d8f8864b3d0eb30292930d288ee178 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 21:21:32 +0000 Subject: [PATCH 02/10] feat: render generated media artifact cards (#3220, @AJV20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG entry moved to ### Added (feature, not fix) - zh-Hant locale: use Traditional 開啟/下載 instead of simplified forms --- CHANGELOG.md | 6 +++++- static/i18n.js | 22 ++++++++++++++++++++++ static/style.css | 7 +++++++ static/ui.js | 8 +++++++- tests/test_media_inline.py | 29 +++++++++++++++++++++++++++-- 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb15111..ab64a6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Added +- Generated local image artifacts now render as compact chat cards with an inline preview, the filename, and localized **Open** / **Download** actions, all served through authenticated `/api/media` URLs (#3220, @AJV20). + ## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile) ### Fixed @@ -81,6 +84,7 @@ ### Fixed - A global `model.context_length` cap (set in config for the default model, e.g. 232000) no longer silently shrinks **non-default** models' real context windows. The cap is now applied only when the session model equals `model.default`; other models (e.g. a 1M-context variant) keep their real metadata window. The guard is applied consistently across the session context-length resolver (`api/routes.py`), the per-turn persistence path, and the live SSE usage payload, and the auto-compress `threshold_tokens` is rescaled to the real cap so the context-window indicator and compression trigger reflect the actual window. The live-usage perf path caches the resolved per-model window once per stream (it runs ~10×/sec during streaming) so non-default-model streams don't take a config/metadata lookup on every metering tick. Backend-only; default-model sessions are unaffected. Closes #3256 (#3263, @allenliang2022). + ## [v0.51.191] — 2026-05-31 — Release FK (stage-batch3 — skills-detail markdown styling + launchd duplicate-start guard) ### Fixed @@ -7365,4 +7369,4 @@ Critical regressions introduced during the server.py split, caught by users and - **SSE loop did not break on `cancel` event** -- connection hung after cancel - **Regression test file added** (`tests/test_regressions.py`): 10 tests, one per introduced bug. These form a permanent regression gate so each class of error can never silently return. ---- \ No newline at end of file +--- diff --git a/static/i18n.js b/static/i18n.js index 2103ea6c..e452307a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1292,6 +1292,8 @@ const LOCALES = { media_audio_label: 'Audio', media_svg_label: 'Diagram', media_video_label: 'Video', + media_open: 'Open', + media_download: 'Download', csv_loading: 'Loading CSV', csv_too_large: 'CSV file too large for inline rendering', csv_no_data: 'CSV file has insufficient data to render as table', @@ -2604,6 +2606,8 @@ const LOCALES = { media_audio_label: 'Audio', media_svg_label: 'Diagramma', media_video_label: 'Video', + media_open: 'Apri', + media_download: 'Scarica', csv_loading: 'Caricamento CSV', csv_too_large: 'File CSV troppo grande per il rendering inline', csv_no_data: 'Il file CSV ha dati insufficienti per il rendering come tabella', @@ -3921,6 +3925,8 @@ const LOCALES = { media_audio_label: '音声', media_svg_label: '図', media_video_label: '動画', + media_open: '開く', + media_download: 'ダウンロード', csv_loading: 'CSV を読み込み中', csv_too_large: 'CSV ファイルが大きすぎてインライン表示できません', csv_no_data: 'CSV ファイルのデータが不足しているためテーブル表示できません', @@ -5110,6 +5116,8 @@ const LOCALES = { media_audio_label: 'Аудио', media_svg_label: 'Диаграмма', media_video_label: 'Видео', + media_open: 'Открыть', + media_download: 'Скачать', csv_loading: 'Загрузка CSV', csv_too_large: 'CSV-файл слишком большой для встроенного отображения', csv_no_data: 'Недостаточно данных в CSV-файле', @@ -6347,6 +6355,8 @@ const LOCALES = { media_audio_label: 'Audio', media_svg_label: 'Diagrama', media_video_label: 'Vídeo', + media_open: 'Abrir', + media_download: 'Descargar', csv_loading: 'Cargando CSV', csv_too_large: 'Archivo CSV demasiado grande para mostrar en línea', csv_no_data: 'El archivo CSV no tiene suficientes datos para mostrar como tabla', @@ -7598,6 +7608,8 @@ const LOCALES = { media_audio_label: 'Audio', media_svg_label: 'Diagramm', media_video_label: 'Video', + media_open: 'Öffnen', + media_download: 'Herunterladen', csv_loading: 'CSV wird geladen', csv_too_large: 'CSV-Datei zu groß für Inline-Anzeige', csv_no_data: 'CSV-Datei enthält nicht genügend Daten', @@ -8854,6 +8866,8 @@ const LOCALES = { media_audio_label: '音频', media_svg_label: '图表', media_video_label: '视频', + media_open: '打开', + media_download: '下载', csv_loading: '加载 CSV', csv_too_large: 'CSV 文件过大,无法内联渲染', csv_no_data: 'CSV 文件数据不足,无法渲染为表格', @@ -10163,6 +10177,8 @@ const LOCALES = { media_audio_label: '音訊', media_svg_label: '圖表', media_video_label: '影片', + media_open: '開啟', + media_download: '下載', csv_loading: '載入 CSV', csv_too_large: 'CSV 檔案過大,無法內嵌渲染', csv_no_data: 'CSV 檔案資料不足,無法渲染為表格', @@ -12607,6 +12623,8 @@ const LOCALES = { media_audio_label: '오디오', media_svg_label: '다이어그램', media_video_label: '비디오', + media_open: '열기', + media_download: '다운로드', csv_loading: 'CSV 로딩 중', csv_too_large: 'CSV 파일이 너무 커서 인라인 렌더링할 수 없습니다', csv_no_data: 'CSV 파일에 표시할 데이터가 부족합니다', @@ -13822,6 +13840,8 @@ const LOCALES = { media_audio_label: 'Audio', media_svg_label: 'Diagramme', media_video_label: 'Vidéo', + media_open: 'Ouvrir', + media_download: 'Télécharger', csv_loading: 'Chargement du fichier CSV', csv_too_large: 'Fichier CSV trop volumineux pour le rendu en ligne', csv_no_data: 'Le fichier CSV ne contient pas suffisamment de données pour être affiché sous forme de tableau', @@ -15165,6 +15185,8 @@ const LOCALES = { media_audio_label: 'Ses', media_svg_label: 'Diyagram', media_video_label: 'Video', + media_open: 'Aç', + media_download: 'İndir', csv_loading: 'CSV yükleniyor', csv_too_large: 'CSV dosyası satır içi oluşturma için çok büyük', csv_no_data: 'CSV dosyasında tablo olarak oluşturmak için yeterli veri yok', diff --git a/static/style.css b/static/style.css index 80911d8a..c777bd74 100644 --- a/static/style.css +++ b/static/style.css @@ -1561,6 +1561,13 @@ .img-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.5);color:#fff;font-size:14px;padding:4px 14px;border-radius:12px;pointer-events:none;} .msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;} .msg-media-link:hover{background:var(--accent-bg-strong);} + .msg-artifact-card{display:inline-flex;align-items:center;gap:10px;max-width:min(100%,360px);margin:6px 8px 6px 0;padding:8px;background:var(--surface);border:1px solid var(--border);border-radius:10px;vertical-align:top;box-shadow:0 2px 10px rgba(0,0,0,.12);} + .msg-artifact-card .msg-media-img{margin:0;flex:0 0 auto;} + .msg-artifact-meta{display:flex;min-width:0;flex-direction:column;gap:6px;align-items:flex-start;} + .msg-artifact-title{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--text);} + .msg-artifact-actions{display:flex;gap:6px;flex-wrap:wrap;} + .msg-artifact-action{display:inline-flex;align-items:center;border:1px solid var(--accent-bg-strong);border-radius:999px;padding:3px 9px;font-size:12px;color:var(--accent-text);text-decoration:none;background:var(--accent-bg);} + .msg-artifact-action:hover{background:var(--accent-bg-strong);} /* ── Inline SVG rendering ── */ .msg-media-svg{display:block;max-width:100%;height:auto;border-radius:6px;margin:6px 0;border:1px solid var(--border);background:var(--surface);} diff --git a/static/ui.js b/static/ui.js index 1c84f293..6892e8e2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3504,6 +3504,12 @@ function renderMd(raw){ : ``; return `
${tag}
${safeName}
`; }; + const localArtifactCard=(src,name)=>{ + const safeSrc=esc(src); + const safeName=esc(name||'image'); + const tt=(typeof t==='function')?t:(key=>({media_open:'Open',media_download:'Download'}[key]||key)); + return `${safeName}${safeName}${tt('media_open')}${tt('media_download')}`; + }; if(/^file:\/\//i.test(ref)){ try{ const u=new URL(ref); @@ -3545,7 +3551,7 @@ function renderMd(raw){ const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):''); const localKind=mediaKindForName(ref); if(localKind==='image'){ - return `${esc(ref.split('/').pop())}`; + return localArtifactCard(apiUrl,ref.split('/').pop()||'image'); } // SVG → inline image (no download, render directly) if(_SVG_EXTS.test(ref)){ diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 9276d9be..86081523 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -26,6 +26,7 @@ from tests._pytest_port import BASE, TEST_STATE_DIR REPO_ROOT = pathlib.Path(__file__).parent.parent UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") +I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") WORKSPACE_JS = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8") @@ -59,6 +60,17 @@ class TestMediaRenderMdStash(unittest.TestCase): self.assertIn("msg-media-link", UI_JS, "restore pass must produce download link for non-image files") + def test_local_image_media_uses_artifact_card(self): + self.assertIn("localArtifactCard", UI_JS) + self.assertIn("msg-artifact-card msg-artifact-card--image", UI_JS) + self.assertIn("msg-artifact-actions", UI_JS) + self.assertIn("t('media_download')", UI_JS) + self.assertIn("t('media_open')", UI_JS) + self.assertIn("media_open:", I18N_JS) + self.assertIn("media_download:", I18N_JS) + self.assertNotIn("downloadUrl=src+(String(src).includes('?')?'&':'?')+'download=1'", UI_JS) + self.assertNotIn("openUrl=src+(String(src).includes('?')?'&':'?')+'inline=1'", UI_JS) + def test_media_api_url_pattern(self): self.assertIn("api/media?path=", UI_JS, "renderMd must build api/media?path=... URL for local files") @@ -125,8 +137,21 @@ class TestMediaCSS(unittest.TestCase): "Full-size toggle class must exist for zoom-on-click") def test_msg_media_link_class_defined(self): - self.assertIn(".msg-media-link", self.CSS, - "Download link style must be defined for non-image media") + self.assertIn( + ".msg-media-link", + self.CSS, + "Download link style must be defined for non-image media", + ) + + def test_generated_artifact_card_css_defined(self): + for cls in [ + ".msg-artifact-card", + ".msg-artifact-meta", + ".msg-artifact-title", + ".msg-artifact-actions", + ".msg-artifact-action", + ]: + self.assertIn(cls, self.CSS) From 546afe8374ef890ace38e18b14ba44b375e70270 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 21:22:16 +0000 Subject: [PATCH 03/10] feat: add manual session title regeneration (#3223, @AJV20) Closes #3106. Adds /api/session/title/regenerate endpoint + session-action menu item. Preserves chronology (touch_updated_at=False), guards read-only and imported sessions, syncs to state.db when Insights sync enabled. Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the regenerate action only instead of broadening the shared _isReadOnlySession() helper, which also gates rename/pin/archive/move/fork. Matches the backend 403 guard. Test updated to assert the scoped shape. --- CHANGELOG.md | 2 + api/routes.py | 53 ++++++++ api/streaming.py | 29 +++++ static/i18n.js | 60 +++++++++ static/sessions.js | 34 +++++ tests/test_1466_sidebar_cancel_clarify.py | 5 +- .../test_session_title_regenerate_controls.py | 123 ++++++++++++++++++ 7 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/test_session_title_regenerate_controls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ab64a6d9..7e772aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Generated local image artifacts now render as compact chat cards with an inline preview, the filename, and localized **Open** / **Download** actions, all served through authenticated `/api/media` URLs (#3220, @AJV20). +- The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. Closes #3106 (#3223, @AJV20). ## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile) @@ -124,6 +125,7 @@ - Agent self-update no longer advertises or applies unreachable release tags when the checkout tracks `main` past an older tag but the newest published tag lives on a divergent side branch (for example `v2026.5.29` → `v2026.5.29.2`). The update checker and apply path now fall through to the configured upstream branch when `git pull --ff-only ` cannot fast-forward, matching the existing #2653/#3140 release-vs-branch routing (#3257, @pamnard). - Added regression coverage pinning `_run_git()`'s UTF-8 decoding (`encoding='utf-8'`, `errors='replace'`) and its defensive `None`-stdout guard, so version detection cannot crash on non-UTF-8 Windows console output (#3254, @zapabob). + ## [v0.51.185] — 2026-05-31 — Release FE (stage-batchE — clarify-card bug-fix batch: identical-prompt dedup + autofill guard + GBK startup crash) ### Fixed diff --git a/api/routes.py b/api/routes.py index a02e6977..b62dffb4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2846,6 +2846,7 @@ from api.streaming import ( _run_agent_streaming, cancel_stream, _materialize_pending_user_turn_before_error, + generate_session_title_for_session, ) from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled from api.run_journal import ( @@ -5738,6 +5739,27 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/sessions/cleanup_zero_message": return _handle_sessions_cleanup(handler, body, zero_only=True) + def _sync_session_title_to_insights(session): + """Write title-only session metadata updates through to state.db when enabled.""" + try: + if not load_settings().get("sync_to_insights"): + return + from api.state_sync import sync_session_usage + + messages = getattr(session, "messages", None) or [] + sync_session_usage( + session_id=session.session_id, + input_tokens=getattr(session, "input_tokens", None) or 0, + output_tokens=getattr(session, "output_tokens", None) or 0, + estimated_cost=getattr(session, "estimated_cost", 0.0), + model=getattr(session, "model", ""), + title=session.title, + message_count=len(messages), + profile=getattr(session, "profile", None), + ) + except Exception: + logger.debug("Failed to update session title in state.db", exc_info=True) + if parsed.path == "/api/session/rename": try: require(body, "session_id", "title") @@ -5754,6 +5776,37 @@ def handle_post(handler, parsed) -> bool: publish_session_list_changed("session_rename") return j(handler, {"session": s.compact()}) + + if parsed.path == "/api/session/title/regenerate": + try: + require(body, "session_id") + except ValueError as e: + return bad(handler, str(e)) + sid = body["session_id"] + prefer_latest = bool(body.get("prefer_latest", False)) + try: + s = get_session(sid) + s = _ensure_full_session_before_mutation(sid, s) + except KeyError: + return bad(handler, "Session not found", 404) + if getattr(s, "read_only", False) or getattr(s, "is_imported", False): + return bad(handler, "Read-only imported sessions cannot be renamed", 403) + next_title, reason, raw_preview = generate_session_title_for_session(s, prefer_latest=prefer_latest) + if not next_title: + return bad(handler, f"Could not generate a better title ({reason or 'empty'})", 422) + with _get_session_agent_lock(sid): + s.title = str(next_title).strip()[:80] or "Untitled" + s.llm_title_generated = True + s.save(touch_updated_at=False) + _sync_session_title_to_insights(s) + publish_session_list_changed("session_title_regenerate") + return j(handler, { + "session": s.compact(), + "title": s.title, + "status": reason, + "raw_preview": (raw_preview or "")[:240], + }) + if parsed.path == "/api/personality/set": try: require(body, "session_id") diff --git a/api/streaming.py b/api/streaming.py index dd9b1f2c..0aff407d 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2263,6 +2263,35 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex logger.debug("Background title refresh failed for session %s", session_id, exc_info=True) + + +def generate_session_title_for_session(session, *, prefer_latest: bool = False, agent=None) -> tuple[Optional[str], str, str]: + """Generate a session title on demand from persisted conversation messages. + + This helper powers explicit UI title-regeneration controls. It intentionally + does not inspect or mutate ``llm_title_generated``; callers decide whether + replacing the current title is allowed, then persist the returned title. + """ + messages = getattr(session, 'messages', None) or [] + if prefer_latest: + user_text, assistant_text = _latest_exchange_snippets(messages) + else: + user_text, assistant_text = _first_exchange_snippets(messages) + if not user_text: + return None, 'empty_user_message', '' + from api import profiles as profiles_api + + with profiles_api.profile_env_for_background_worker(session, "manual title regeneration", logger_override=logger): + next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent) + if next_title: + return next_title, llm_status, raw_preview + fallback_title = _fallback_title_from_exchange(user_text, assistant_text) + if fallback_title and not _is_generic_fallback_title(fallback_title): + reason = f'local_summary:{llm_status}' if llm_status else 'local_summary' + return fallback_title, reason, raw_preview + return None, llm_status or 'empty_title', raw_preview + + def _preserve_pre_compression_snapshot(s, old_sid: str) -> None: """Persist old_sid as a read-only pre-compression snapshot. diff --git a/static/i18n.js b/static/i18n.js index e452307a..12860a37 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -427,6 +427,11 @@ const LOCALES = { path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', session_rename_desc: 'Edit the title of this conversation', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Copy conversation link', session_copy_link_desc: 'Copy a direct link to this conversation', session_link_copied: 'Conversation link copied to clipboard', @@ -1749,6 +1754,11 @@ const LOCALES = { path_copy_failed: 'Copia percorso fallita: ', session_rename: 'Rinomina conversazione', session_rename_desc: 'Modifica il titolo di questa conversazione', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Copia link conversazione', session_copy_link_desc: 'Copia un link diretto a questa conversazione', session_link_copied: 'Link conversazione copiato negli appunti', @@ -3063,6 +3073,11 @@ const LOCALES = { path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', session_rename_desc: 'この会話のタイトルを編集', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: '会話リンクをコピー', session_copy_link_desc: 'この会話への直接リンクをコピー', session_link_copied: '会話リンクをクリップボードにコピーしました', @@ -4302,6 +4317,11 @@ const LOCALES = { path_copy_failed: 'Не удалось скопировать путь: ', session_rename: 'Переименовать беседу', session_rename_desc: 'Изменить название этой беседы', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Скопировать ссылку на беседу', session_copy_link_desc: 'Скопировать прямую ссылку на эту беседу', session_link_copied: 'Ссылка на беседу скопирована в буфер обмена', @@ -5533,6 +5553,11 @@ const LOCALES = { path_copy_failed: 'Error al copiar la ruta: ', session_rename: 'Renombrar conversación', session_rename_desc: 'Editar el título de esta conversación', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Copiar enlace de conversación', session_copy_link_desc: 'Copia un enlace directo a esta conversación', session_link_copied: 'Enlace de conversación copiado al portapapeles', @@ -6768,6 +6793,11 @@ const LOCALES = { path_copy_failed: 'Pfad konnte nicht kopiert werden: ', session_rename: 'Unterhaltung umbenennen', session_rename_desc: 'Titel dieser Unterhaltung bearbeiten', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Unterhaltungslink kopieren', session_copy_link_desc: 'Direkten Link zu dieser Unterhaltung kopieren', session_link_copied: 'Unterhaltungslink in die Zwischenablage kopiert', @@ -8055,6 +8085,11 @@ const LOCALES = { path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', session_rename_desc: '\u7f16\u8f91\u6b64\u5bf9\u8bdd\u7684\u6807\u9898', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: '复制对话链接', session_copy_link_desc: '复制此对话的直接链接', session_link_copied: '对话链接已复制到剪贴板', @@ -9205,6 +9240,11 @@ const LOCALES = { path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', session_rename_desc: '\u7de8\u8f2f\u6b64\u5c0d\u8a71\u7684\u6a19\u984c', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: '複製對話連結', session_copy_link_desc: '複製此對話的直接連結', session_link_copied: '對話連結已複製到剪貼簿', @@ -10598,6 +10638,11 @@ const LOCALES = { path_copy_failed: 'Falha ao copiar caminho: ', session_rename: 'Renomear conversa', session_rename_desc: 'Editar o título desta conversa', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Copiar link da conversa', session_copy_link_desc: 'Copia um link direto para esta conversa', session_link_copied: 'Link da conversa copiado para a área de transferência', @@ -11808,6 +11853,11 @@ const LOCALES = { path_copy_failed: '경로 복사 실패: ', session_rename: '대화 이름 변경', session_rename_desc: '이 대화의 제목 편집', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: '대화 링크 복사', session_copy_link_desc: '이 대화로 바로 가는 링크 복사', session_link_copied: '대화 링크가 클립보드에 복사되었습니다', @@ -13050,6 +13100,11 @@ const LOCALES = { path_copy_failed: 'Échec de la copie du chemin :', session_rename: 'Renommer la conversation', session_rename_desc: 'Modifier le titre de cette conversation', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Copier le lien de conversation', session_copy_link_desc: 'Copier un lien direct vers cette conversation', session_link_copied: 'Lien de conversation copié dans le presse-papiers', @@ -14372,6 +14427,11 @@ const LOCALES = { path_copy_failed: 'Yol kopyalanamadı:', session_rename: 'Konuşmayı yeniden adlandır', session_rename_desc: 'Bu görüşmenin başlığını düzenleyin', + session_title_regenerate: 'Regenerate title', + session_title_regenerate_desc: 'Ask Hermes to generate a fresh title from the conversation', + session_title_regenerating: 'Regenerating title…', + session_title_regenerated: (title) => `Title regenerated: ${title}`, + session_title_regenerate_failed: 'Regenerate title failed: ', session_copy_link: 'Konuşma bağlantısını kopyala', session_copy_link_desc: 'Bu konuşmaya doğrudan bağlantı kopyala', session_link_copied: 'Konuşma bağlantısı panoya kopyalandı', diff --git a/static/sessions.js b/static/sessions.js index 74f48849..3c3086f2 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -10,6 +10,7 @@ const ICONS={ trash:'', more:'', edit:'', + spark:'', link:'', }; @@ -2304,6 +2305,39 @@ function _openSessionActionMenu(session, anchorEl){ } )); } + // Title regeneration matches the backend guard (api/routes.py rejects + // read_only OR is_imported with 403). read_only sessions already bailed at + // the isReadOnly early-return above; skip imported sessions here so the + // action is hidden rather than failing with a 403 toast. This keeps the + // is_imported gate scoped to regenerate instead of broadening the shared + // _isReadOnlySession() helper (which gates rename/pin/archive/etc.). + if(!session.is_imported){ + menu.appendChild(_buildSessionAction( + t('session_title_regenerate'), + t('session_title_regenerate_desc'), + ICONS.spark, + async()=>{ + closeSessionActionMenu(); + try{ + if(typeof showToast==='function') showToast(t('session_title_regenerating'), 1600); + const response=await api('/api/session/title/regenerate',{method:'POST',body:JSON.stringify({session_id:session.session_id})}); + const nextTitle=(response&&response.title)||(response&&response.session&&response.session.title)||''; + if(nextTitle){ + session.title=nextTitle; + const cached=(_allSessions||[]).find(item=>item&&item.session_id===session.session_id); + if(cached) cached.title=nextTitle; + if(S.session&&S.session.session_id===session.session_id){S.session.title=nextTitle;syncTopbar();} + renderSessionListFromCache(); + } + if(typeof showToast==='function') showToast(t('session_title_regenerated', nextTitle||t('untitled')), 2400); + }catch(err){ + const msg=t('session_title_regenerate_failed')+(err&&err.message?err.message:String(err)); + setStatus(msg); + if(typeof showToast==='function') showToast(msg,3000,'error'); + } + } + )); + } if(!isExternalSession){ if(session.worktree_path){ menu.appendChild(_buildSessionAction( diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index ee182a4c..83d2fa01 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -24,11 +24,12 @@ class TestSidebarCancelAction: # Window bumped from 3200 → 4400 in #1764 to accommodate the new # Rename action item, then to 5200 in #2111 for response-aware archive # toast handling, then to 6400 in #2294 for the new "Hide from list" - # action prepended for external sessions. + # action prepended for external sessions, then to 7600 in #3106 for + # manual title regeneration controls. # The `session.active_stream_id` / cancelSessionStream / delete checks # are positional further down in the function, so growing the prefix # required growing this read window. - body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 6400) + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 7600) assert "session.active_stream_id" in body, ( "sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId" ) diff --git a/tests/test_session_title_regenerate_controls.py b/tests/test_session_title_regenerate_controls.py new file mode 100644 index 00000000..2257cac4 --- /dev/null +++ b/tests/test_session_title_regenerate_controls.py @@ -0,0 +1,123 @@ +"""Regression coverage for manual session title regeneration controls (#3106).""" + +from pathlib import Path +from unittest.mock import MagicMock + +import api.streaming as streaming + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") +STREAMING_PY = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8") +CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + + +def test_session_action_menu_exposes_regenerate_title_control(): + assert "session_title_regenerate" in SESSIONS_JS + assert "session_title_regenerate_desc" in SESSIONS_JS + assert "ICONS.spark" in SESSIONS_JS + assert "api('/api/session/title/regenerate'" in SESSIONS_JS + assert "renderSessionListFromCache();" in SESSIONS_JS + + +def test_imported_sessions_skip_regenerate_action_without_broadening_shared_gate(): + # The shared _isReadOnlySession() helper must stay scoped to read_only flags + # so it does not silently disable rename/pin/archive/etc. for imported + # sessions. The is_imported guard is scoped to the regenerate action only, + # matching the backend 403 guard in api/routes.py. + helper_idx = SESSIONS_JS.index("function _isReadOnlySession(session)") + next_helper_idx = SESSIONS_JS.index("function _sourceKeyForSession", helper_idx) + helper_block = SESSIONS_JS[helper_idx:next_helper_idx] + assert "session.is_imported" not in helper_block, ( + "_isReadOnlySession must not include is_imported — it gates rename/pin/archive too" + ) + # The regenerate action is gated on !session.is_imported next to its api call. + regen_idx = SESSIONS_JS.index("api('/api/session/title/regenerate'") + guard_window = SESSIONS_JS[regen_idx - 600:regen_idx] + assert "if(!session.is_imported){" in guard_window + + +def test_regenerate_title_i18n_and_changelog_entries_exist(): + for key in [ + "session_title_regenerate", + "session_title_regenerate_desc", + "session_title_regenerating", + "session_title_regenerated", + "session_title_regenerate_failed", + ]: + assert key in I18N_JS + assert "session action menu can regenerate conversation titles" in CHANGELOG + assert "#3106" in CHANGELOG + + +def test_regenerate_endpoint_persists_generated_title_without_reordering_sidebar(): + endpoint_idx = ROUTES_PY.index('"/api/session/title/regenerate"') + next_endpoint_idx = ROUTES_PY.index('"/api/personality/set"', endpoint_idx) + block = ROUTES_PY[endpoint_idx:next_endpoint_idx] + assert "generate_session_title_for_session" in block + assert "s.llm_title_generated = True" in block + assert "s.save(touch_updated_at=False)" in block + assert "_sync_session_title_to_insights(s)" in block + assert 'publish_session_list_changed("session_title_regenerate")' in block + assert "Read-only imported sessions cannot be renamed" in block + + +def test_regenerate_endpoint_syncs_title_to_state_db_when_enabled(): + helper_idx = ROUTES_PY.index("def _sync_session_title_to_insights") + endpoint_idx = ROUTES_PY.index('"/api/session/title/regenerate"') + helper_block = ROUTES_PY[helper_idx:endpoint_idx] + assert 'load_settings().get("sync_to_insights")' in helper_block + assert "sync_session_usage" in helper_block + assert "title=session.title" in helper_block + assert "message_count=len(messages)" in helper_block + assert "profile=getattr(session, \"profile\", None)" in helper_block + + +def test_streaming_helper_generates_title_from_persisted_transcript(monkeypatch): + session = MagicMock() + session.messages = [ + {"role": "user", "content": "Please fix the stale sidebar title controls"}, + {"role": "assistant", "content": "I will add a regenerate-title action."}, + ] + + class _ProfileEnv: + def __enter__(self): + return None + def __exit__(self, exc_type, exc, tb): + return False + + import api.profiles as profiles_api + monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *args, **kwargs: _ProfileEnv()) + monkeypatch.setattr( + streaming, + "_generate_llm_session_title_via_aux", + lambda user, assistant, agent=None: ("Sidebar title controls", "llm", "raw"), + ) + + title, status, raw = streaming.generate_session_title_for_session(session) + assert title == "Sidebar title controls" + assert status == "llm" + assert raw == "raw" + + +def test_streaming_helper_has_local_fallback_when_llm_title_is_empty(monkeypatch): + session = MagicMock() + session.messages = [ + {"role": "user", "content": "Can you triage this GitHub issue and PR review?"}, + {"role": "assistant", "content": "Sure."}, + ] + + class _ProfileEnv: + def __enter__(self): + return None + def __exit__(self, exc_type, exc, tb): + return False + + import api.profiles as profiles_api + monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *args, **kwargs: _ProfileEnv()) + monkeypatch.setattr(streaming, "_generate_llm_session_title_via_aux", lambda *args, **kwargs: (None, "llm_empty", "")) + + title, status, _raw = streaming.generate_session_title_for_session(session) + assert title == "GitHub Issue Triage" + assert status == "local_summary:llm_empty" From c1156b4c675c6e71fb30ce1da3608d901baf7217 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 21:56:26 +0000 Subject: [PATCH 04/10] fix(#3337): prevent Prism highlight leaking across workspace files Maintainer fix on stage (browser-test catch): Prism.highlightElement() propagates the language-* class onto the parent
, so previewing a
.css file then a .txt file rendered the plain text with CSS grammar.
Strip any stale language-* from #previewCode before each render and only
call highlightElement when a language was assigned. Adds regression test
(tests/test_issue3337_workspace_preview_highlight.py) and bumps the
_openSessionActionMenu scan window in test_1466 to cover the new
Regenerate-title action lines (#3223).
---
 static/workspace.js                           | 11 +++-
 tests/test_1466_sidebar_cancel_clarify.py     |  6 ++-
 ...t_issue3337_workspace_preview_highlight.py | 53 +++++++++++++++++++
 3 files changed, 67 insertions(+), 3 deletions(-)
 create mode 100644 tests/test_issue3337_workspace_preview_highlight.py

diff --git a/static/workspace.js b/static/workspace.js
index 6c911ca9..80825116 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -692,8 +692,17 @@ async function openFile(path, opts={}){
       if(lang) codeEl.className='language-'+lang;
       const pre=$('previewCode');
       pre.textContent='';
+      // Prism.highlightElement() propagates the language-* class onto the
+      // parent 
, so a previously-previewed code file leaves e.g.
+      // "language-css" on #previewCode. A subsequent plain-text file builds a
+      // class-less , and Prism walks up to that stale ancestor class and
+      // mis-highlights prose. Strip any inherited language-* token from the
+      // 
 before each render so highlighting never leaks across files.
+      pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
       pre.appendChild(codeEl);
-      if(typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
+      // Only invoke Prism when we actually assigned a language; otherwise the
+      // class-less  would inherit any ancestor language-* class.
+      if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function'){
         Prism.highlightElement(codeEl);
       }
     }catch(e){
diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py
index 83d2fa01..ff6ddd31 100644
--- a/tests/test_1466_sidebar_cancel_clarify.py
+++ b/tests/test_1466_sidebar_cancel_clarify.py
@@ -77,8 +77,10 @@ class TestSidebarCancelAction:
         """Session action menu should hide duplicate/delete for CLI-origin sessions."""
         # Window bumped 3600 → 4800 in #1764 (Rename action prepended), then
         # to 5200 in #2111 for response-aware archive toast handling, then
-        # to 6400 in #2294 for the "Hide from list" action on external sessions.
-        body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 6400)
+        # to 6400 in #2294 for the "Hide from list" action on external sessions,
+        # then to 7200 in #3223 for the "Regenerate title" action (gated on
+        # !session.is_imported) added between Stop-response and the worktree/delete block.
+        body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 7200)
         assert "const isCliSession = _isCliSession(session);" in body
         assert "const isExternalSession = isMessagingSession || isCliSession;" in body
         assert "if(!isExternalSession)" in body
diff --git a/tests/test_issue3337_workspace_preview_highlight.py b/tests/test_issue3337_workspace_preview_highlight.py
new file mode 100644
index 00000000..b94ecc68
--- /dev/null
+++ b/tests/test_issue3337_workspace_preview_highlight.py
@@ -0,0 +1,53 @@
+"""Regression tests for workspace file-preview syntax highlighting (#3337).
+
+#3337 added Prism.js syntax highlighting to the workspace code preview. The
+contributor PR shipped no test of its own, and a maintainer browser-test caught
+a cross-file highlight-leak bug: Prism.highlightElement() propagates the
+language-* class onto the parent 
, so previewing a .css file then a .txt
+file rendered the plain text with CSS grammar. These tests pin both the feature
+and the fix.
+"""
+from pathlib import Path
+
+WORKSPACE_JS = (Path(__file__).resolve().parent.parent / "static" / "workspace.js").read_text(encoding="utf-8")
+
+
+def _open_file_body() -> str:
+    start = WORKSPACE_JS.index("async function openFile(")
+    # The plain code/text branch is the last else block before the function ends.
+    return WORKSPACE_JS[start:start + 8000]
+
+
+def test_workspace_preview_assigns_prism_language_class():
+    body = _open_file_body()
+    assert "_prismLanguageForPath(path)" in body
+    assert "codeEl.className='language-'+lang" in body
+    assert "Prism.highlightElement(codeEl)" in body
+
+
+def test_prism_language_map_covers_common_extensions():
+    assert "_PRISM_LANG_MAP" in WORKSPACE_JS
+    for ext_token in ["py:'python'", "js:'javascript'", "css:'css'", "json:'json'"]:
+        assert ext_token in WORKSPACE_JS, f"language map should include {ext_token}"
+    # txt/log/csv intentionally map to '' (no highlighting).
+    assert "txt:''" in WORKSPACE_JS
+
+
+def test_plain_text_files_do_not_inherit_prior_file_highlighting():
+    """Cross-file leak fix: a plain-text preview after a code preview must not
+    inherit the previous file's language. Two guards make this hold:
+
+    1. The 
 ancestor's stale language-* class is stripped before each render
+       (Prism propagates the class to the parent, so it would otherwise leak).
+    2. Prism.highlightElement is only called when a non-empty language was
+       assigned, so a class-less  never walks up to an ancestor class.
+    """
+    body = _open_file_body()
+    # Guard 1: stale language-* token stripped from the 
 before append.
+    assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in body, (
+        "previewCode 
 must have stale language-* classes stripped each render"
+    )
+    # Guard 2: highlightElement gated on a truthy lang.
+    assert "if(lang&&typeof Prism!=='undefined'&&typeof Prism.highlightElement==='function')" in body, (
+        "Prism.highlightElement must only run when a language was assigned"
+    )

From 8ce02caf112651a3b9bb23dbf590f3e0546a7e5a Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Mon, 1 Jun 2026 22:48:01 +0000
Subject: [PATCH 05/10] =?UTF-8?q?iterate(stage-hi1):=20Nathan=20UX=20feedb?=
 =?UTF-8?q?ack=20=E2=80=94=20#3220=20clean=20image+hover-download,=20#3337?=
 =?UTF-8?q?=20uniform=20code=20bg,=20#3223=20menu=20clip=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

#3220: redesign generated-image rendering from a permanent bordered card
(filename + Open/Download buttons) to a clean inline image with click-to-zoom
lightbox + a hover/focus-revealed Download overlay, matching ChatGPT/Claude/
Gemini. Drops redundant Open (lightbox already covers it). Tests updated.

#3337: fix two-tone code background — Prism's prism-tomorrow theme styled the
parent 
 gray while the  was navy var(--code-bg), so dark theme showed
a gray frame around navy code. Override BOTH .preview-code[class*=language-] and
its  to var(--code-bg) (mirrors the chat code-block fix at .msg-body pre).

#3223: fix action-menu clipping — the new Regenerate-title row made the 9-item
menu tall enough to overflow the viewport bottom when opened on a top-anchored
row at short viewports (e.g. 1280x720). _positionSessionActionMenu now clamps
the menu within both viewport edges and caps max-height with scroll when the
menu is taller than the viewport.
---
 CHANGELOG.md               |  2 +-
 static/sessions.js         | 20 ++++++++++++++++++--
 static/style.css           | 27 +++++++++++++++++++--------
 static/ui.js               |  8 +++++++-
 tests/test_media_inline.py | 31 ++++++++++++++++++++-----------
 5 files changed, 65 insertions(+), 23 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e772aa8..f314419a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
 ## [Unreleased]
 
 ### Added
-- Generated local image artifacts now render as compact chat cards with an inline preview, the filename, and localized **Open** / **Download** actions, all served through authenticated `/api/media` URLs (#3220, @AJV20).
+- Generated local image artifacts now render as a clean inline image (with click-to-zoom lightbox) plus a hover/focus-revealed **Download** action overlaid on the image, served through authenticated `/api/media` URLs — matching the common AI-chat pattern of letting the image be the hero rather than wrapping it in a permanent card (#3220, @AJV20).
 - The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. Closes #3106 (#3223, @AJV20).
 
 ## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile)
diff --git a/static/sessions.js b/static/sessions.js
index 3c3086f2..51079c58 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -2032,12 +2032,28 @@ function _positionSessionActionMenu(anchorEl){
   if(left+menuW>window.innerWidth-8) left=window.innerWidth-menuW-8;
   _sessionActionMenu.style.left=left+'px';
   _sessionActionMenu.style.top='8px';
+  // Reset any prior clamp so we measure the menu's natural height.
+  _sessionActionMenu.style.maxHeight='';
   const menuH=_sessionActionMenu.offsetHeight || 0;
+  const margin=8;
+  const maxAvail=window.innerHeight-margin*2;
   let top=rect.bottom+6;
-  if(top+menuH>window.innerHeight-8 && rect.top>menuH+12){
+  // Prefer flipping above the row when the menu would overflow the bottom and
+  // there's room above.
+  if(top+menuH>window.innerHeight-margin && rect.top>menuH+12){
     top=rect.top-menuH-6;
   }
-  if(top<8) top=8;
+  // If the menu is taller than the viewport, or still overflows after the flip
+  // attempt (e.g. a top-anchored row with a tall menu and no room above), cap
+  // its height to the viewport and let it scroll instead of clipping off-screen.
+  if(menuH>maxAvail){
+    _sessionActionMenu.style.maxHeight=maxAvail+'px';
+    top=margin;
+  } else {
+    // Clamp vertically so the whole menu stays on-screen at both edges.
+    if(top+menuH>window.innerHeight-margin) top=window.innerHeight-margin-menuH;
+    if(top (.preview-code), so without overriding the 
 too you get a gray
+     prism frame around the navy var(--code-bg)  — a jarring two-tone block
+     on dark themes. Mirror the chat-code-block fix (.msg-body pre[class*=...]):
+     force BOTH surfaces to var(--code-bg) so the code panel reads as one tone. */
+  .preview-code[class*="language-"],.preview-code code[class*="language-"]{background:var(--code-bg) !important;}
   /* Image preview */
   .preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;}
   .preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);}
diff --git a/static/ui.js b/static/ui.js
index 6892e8e2..a8b6ff1c 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -3508,7 +3508,13 @@ function renderMd(raw){
       const safeSrc=esc(src);
       const safeName=esc(name||'image');
       const tt=(typeof t==='function')?t:(key=>({media_open:'Open',media_download:'Download'}[key]||key));
-      return `${safeName}${safeName}${tt('media_open')}${tt('media_download')}`;
+      // Clean inline image (keeps the existing .msg-media-img lightbox-on-click
+      // behavior) with a hover/focus-revealed Download action overlaid top-right,
+      // matching the ChatGPT/Claude/Gemini pattern. The image stays the hero —
+      // no permanent card chrome. Download is the one affordance the lightbox
+      // (zoom-on-click) doesn't already provide.
+      const dlSvg='';
+      return `${safeName}${dlSvg}`;
     };
     if(/^file:\/\//i.test(ref)){
       try{
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index 86081523..b0ae101f 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -60,14 +60,20 @@ class TestMediaRenderMdStash(unittest.TestCase):
         self.assertIn("msg-media-link", UI_JS,
                       "restore pass must produce download link for non-image files")
 
-    def test_local_image_media_uses_artifact_card(self):
+    def test_local_image_media_uses_clean_image_with_hover_download(self):
+        # #3220 redesign: generated local images render as a clean inline image
+        # (keeping the lightbox-on-click) with a hover/focus-revealed Download
+        # overlay — matching the ChatGPT/Claude/Gemini pattern — instead of a
+        # permanent bordered card with always-visible Open/Download buttons.
         self.assertIn("localArtifactCard", UI_JS)
-        self.assertIn("msg-artifact-card msg-artifact-card--image", UI_JS)
-        self.assertIn("msg-artifact-actions", UI_JS)
+        self.assertIn("msg-artifact-image", UI_JS)
+        self.assertIn("msg-artifact-download", UI_JS)
+        self.assertIn("msg-media-img", UI_JS)
         self.assertIn("t('media_download')", UI_JS)
-        self.assertIn("t('media_open')", UI_JS)
-        self.assertIn("media_open:", I18N_JS)
         self.assertIn("media_download:", I18N_JS)
+        # The clean-image redesign drops the permanent card chrome.
+        self.assertNotIn("msg-artifact-card", UI_JS)
+        self.assertNotIn("msg-artifact-actions", UI_JS)
         self.assertNotIn("downloadUrl=src+(String(src).includes('?')?'&':'?')+'download=1'", UI_JS)
         self.assertNotIn("openUrl=src+(String(src).includes('?')?'&':'?')+'inline=1'", UI_JS)
 
@@ -143,15 +149,18 @@ class TestMediaCSS(unittest.TestCase):
             "Download link style must be defined for non-image media",
         )
 
-    def test_generated_artifact_card_css_defined(self):
+    def test_generated_artifact_image_css_defined(self):
+        # #3220 redesign: clean image + hover-revealed download overlay.
         for cls in [
-            ".msg-artifact-card",
-            ".msg-artifact-meta",
-            ".msg-artifact-title",
-            ".msg-artifact-actions",
-            ".msg-artifact-action",
+            ".msg-artifact-image",
+            ".msg-artifact-download",
         ]:
             self.assertIn(cls, self.CSS)
+        # Hover/focus reveals the download button (hidden by default).
+        self.assertIn(".msg-artifact-image:hover .msg-artifact-download", self.CSS)
+        # The old permanent-card classes are gone.
+        self.assertNotIn(".msg-artifact-card", self.CSS)
+        self.assertNotIn(".msg-artifact-action", self.CSS)
 
 
 

From 031a3ce2e933f87bdbe7feb338bea19b53c88ddc Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Mon, 1 Jun 2026 23:03:48 +0000
Subject: [PATCH 06/10] polish(#3220): esc() download label + scrub orphaned
 media_open i18n keys

Opus iter2 SHOULD-FIX (both non-blocking, applied for cleanliness):
- esc() the media_download title/aria-label so a future translator's quote
  can't break out of the attribute (defense-in-depth).
- Remove the now-dead media_open key from all 11 locales + the ui.js fallback
  dict (the Open button was dropped in the clean-image redesign).
---
 static/i18n.js | 11 -----------
 static/ui.js   |  5 +++--
 2 files changed, 3 insertions(+), 13 deletions(-)

diff --git a/static/i18n.js b/static/i18n.js
index 12860a37..04ff707d 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -1297,7 +1297,6 @@ const LOCALES = {
     media_audio_label: 'Audio',
     media_svg_label: 'Diagram',
     media_video_label: 'Video',
-    media_open: 'Open',
     media_download: 'Download',
     csv_loading: 'Loading CSV',
     csv_too_large: 'CSV file too large for inline rendering',
@@ -2616,7 +2615,6 @@ const LOCALES = {
     media_audio_label: 'Audio',
     media_svg_label: 'Diagramma',
     media_video_label: 'Video',
-    media_open: 'Apri',
     media_download: 'Scarica',
     csv_loading: 'Caricamento CSV',
     csv_too_large: 'File CSV troppo grande per il rendering inline',
@@ -3940,7 +3938,6 @@ const LOCALES = {
     media_audio_label: '音声',
     media_svg_label: '図',
     media_video_label: '動画',
-    media_open: '開く',
     media_download: 'ダウンロード',
     csv_loading: 'CSV を読み込み中',
     csv_too_large: 'CSV ファイルが大きすぎてインライン表示できません',
@@ -5136,7 +5133,6 @@ const LOCALES = {
     media_audio_label: 'Аудио',
     media_svg_label: 'Диаграмма',
     media_video_label: 'Видео',
-    media_open: 'Открыть',
     media_download: 'Скачать',
     csv_loading: 'Загрузка CSV',
     csv_too_large: 'CSV-файл слишком большой для встроенного отображения',
@@ -6380,7 +6376,6 @@ const LOCALES = {
     media_audio_label: 'Audio',
     media_svg_label: 'Diagrama',
     media_video_label: 'Vídeo',
-    media_open: 'Abrir',
     media_download: 'Descargar',
     csv_loading: 'Cargando CSV',
     csv_too_large: 'Archivo CSV demasiado grande para mostrar en línea',
@@ -7638,7 +7633,6 @@ const LOCALES = {
     media_audio_label: 'Audio',
     media_svg_label: 'Diagramm',
     media_video_label: 'Video',
-    media_open: 'Öffnen',
     media_download: 'Herunterladen',
     csv_loading: 'CSV wird geladen',
     csv_too_large: 'CSV-Datei zu groß für Inline-Anzeige',
@@ -8901,7 +8895,6 @@ const LOCALES = {
     media_audio_label: '音频',
     media_svg_label: '图表',
     media_video_label: '视频',
-    media_open: '打开',
     media_download: '下载',
     csv_loading: '加载 CSV',
     csv_too_large: 'CSV 文件过大,无法内联渲染',
@@ -10217,7 +10210,6 @@ const LOCALES = {
     media_audio_label: '音訊',
     media_svg_label: '圖表',
     media_video_label: '影片',
-    media_open: '開啟',
     media_download: '下載',
     csv_loading: '載入 CSV',
     csv_too_large: 'CSV 檔案過大,無法內嵌渲染',
@@ -12673,7 +12665,6 @@ const LOCALES = {
     media_audio_label: '오디오',
     media_svg_label: '다이어그램',
     media_video_label: '비디오',
-    media_open: '열기',
     media_download: '다운로드',
     csv_loading: 'CSV 로딩 중',
     csv_too_large: 'CSV 파일이 너무 커서 인라인 렌더링할 수 없습니다',
@@ -13895,7 +13886,6 @@ const LOCALES = {
     media_audio_label: 'Audio',
     media_svg_label: 'Diagramme',
     media_video_label: 'Vidéo',
-    media_open: 'Ouvrir',
     media_download: 'Télécharger',
     csv_loading: 'Chargement du fichier CSV',
     csv_too_large: 'Fichier CSV trop volumineux pour le rendu en ligne',
@@ -15245,7 +15235,6 @@ const LOCALES = {
     media_audio_label: 'Ses',
     media_svg_label: 'Diyagram',
     media_video_label: 'Video',
-    media_open: 'Aç',
     media_download: 'İndir',
     csv_loading: 'CSV yükleniyor',
     csv_too_large: 'CSV dosyası satır içi oluşturma için çok büyük',
diff --git a/static/ui.js b/static/ui.js
index a8b6ff1c..0b4b260d 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -3507,14 +3507,15 @@ function renderMd(raw){
     const localArtifactCard=(src,name)=>{
       const safeSrc=esc(src);
       const safeName=esc(name||'image');
-      const tt=(typeof t==='function')?t:(key=>({media_open:'Open',media_download:'Download'}[key]||key));
+      const tt=(typeof t==='function')?t:(key=>({media_download:'Download'}[key]||key));
       // Clean inline image (keeps the existing .msg-media-img lightbox-on-click
       // behavior) with a hover/focus-revealed Download action overlaid top-right,
       // matching the ChatGPT/Claude/Gemini pattern. The image stays the hero —
       // no permanent card chrome. Download is the one affordance the lightbox
       // (zoom-on-click) doesn't already provide.
+      const dlLabel=esc(tt('media_download'));
       const dlSvg='';
-      return `${safeName}${dlSvg}`;
+      return `${safeName}${dlSvg}`;
     };
     if(/^file:\/\//i.test(ref)){
       try{

From e5ddb2ae1fe8bdff9c42415c36ebf0ad04c780cb Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Mon, 1 Jun 2026 23:44:31 +0000
Subject: [PATCH 07/10] iterate(stage-hi1 v2): #3220 larger generated images,
 #3223 compact icon+label menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Nathan feedback round 2:
- #3220: generated images were rendering at the 120x90 upload-thumbnail size
  (too small for the subject of the message). Now render at natural aspect
  ratio up to 360px (max-height 360, responsive max-width), lightbox preserved.
- #3223: drop the per-item subtitle/description lines from the session action
  menu — show only icon + label (VS Code / browser / ChatGPT pattern). The
  description is preserved as a hover tooltip (title=). Makes the menu ~40%
  shorter, less crowded, and structurally less prone to viewport clipping.
---
 static/sessions.js |  7 ++++++-
 static/style.css   | 13 +++++++++----
 2 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/static/sessions.js b/static/sessions.js
index 51079c58..a3a5bb45 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -2061,12 +2061,17 @@ function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
   const opt=document.createElement('button');
   opt.type='button';
   opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
+  // Compact context-menu shape (#3223 redesign, Nathan 2026-06-01): show only
+  // icon + label, matching VS Code / browser / ChatGPT conversation menus. The
+  // descriptive `meta` is preserved as a hover tooltip (title=) so the
+  // information stays discoverable without consuming permanent vertical space —
+  // this also keeps the menu short enough to avoid viewport clipping.
+  if(meta) opt.title=meta;
   opt.innerHTML=
     ``
       + `${icon}`
       + ``
         + `${esc(label)}`
-        + (meta?`${esc(meta)}`:'')
       + ``
     + ``;
   opt.onclick=async(e)=>{
diff --git a/static/style.css b/static/style.css
index f6881ab0..c36a0fe6 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1012,7 +1012,7 @@
   .session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;}
   .session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);}
   .session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;}
-  .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:10px 14px;}
+  .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:8px 14px;}
   .session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;}
   .session-action-opt:hover .ws-opt-icon{color:var(--text);opacity:1;}
   .session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
@@ -1565,9 +1565,14 @@
      hover/focus-revealed Download button overlaid top-right. The image keeps
      its .msg-media-img lightbox-on-click behavior; no permanent card chrome —
      matches the ChatGPT/Claude/Gemini pattern of letting the image be the hero. */
-  .msg-artifact-image{position:relative;display:inline-block;vertical-align:bottom;margin:3px 4px 3px 0;}
-  .msg-artifact-image .msg-media-img{margin:0;display:block;}
-  .msg-artifact-download{position:absolute;top:6px;right:6px;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:rgba(0,0,0,.55);color:#fff;text-decoration:none;opacity:0;transform:translateY(-2px);transition:opacity .12s,transform .12s,background .12s;pointer-events:none;}
+  .msg-artifact-image{position:relative;display:inline-block;vertical-align:bottom;margin:6px 4px 6px 0;}
+  /* Generated images are the subject of the message, not a small attachment
+     thumbnail — render them prominently at natural aspect ratio (the base
+     .msg-media-img is a cropped 120x90 upload thumbnail). Capped + responsive so
+     they never overflow the message column or a mobile viewport. Lightbox-on-click
+     (cursor:zoom-in) is preserved. */
+  .msg-artifact-image .msg-media-img{margin:0;display:block;width:auto;height:auto;max-width:min(360px,100%);max-height:360px;object-fit:contain;border-radius:8px;cursor:zoom-in;}
+  .msg-artifact-download{position:absolute;top:8px;right:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(0,0,0,.55);color:#fff;text-decoration:none;opacity:0;transform:translateY(-2px);transition:opacity .12s,transform .12s,background .12s;pointer-events:none;}
   .msg-artifact-image:hover .msg-artifact-download,.msg-artifact-image:focus-within .msg-artifact-download{opacity:1;transform:translateY(0);pointer-events:auto;}
   .msg-artifact-download:hover{background:rgba(0,0,0,.78);}
   .msg-artifact-download:focus-visible{opacity:1;pointer-events:auto;outline:2px solid var(--accent-bg-strong);outline-offset:1px;}

From a66a008ad3f95d707312ccafd79272e945abb5d0 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Mon, 1 Jun 2026 23:49:10 +0000
Subject: [PATCH 08/10] fix(#3220): shrink-wrap artifact-image wrapper so
 download button overlays the image

The .msg-artifact-image span was stretching wider than the image (inline-block
in a block context filled the line), so the absolutely-positioned download
button (right:8px of the wrapper) floated ~240px to the right of the image.
Add width:fit-content + max-width so the wrapper hugs the image; the button now
correctly overlays the image's top-right corner. line-height:0 removes inline
descender gap.
---
 static/style.css | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/static/style.css b/static/style.css
index c36a0fe6..e63e673b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1565,7 +1565,7 @@
      hover/focus-revealed Download button overlaid top-right. The image keeps
      its .msg-media-img lightbox-on-click behavior; no permanent card chrome —
      matches the ChatGPT/Claude/Gemini pattern of letting the image be the hero. */
-  .msg-artifact-image{position:relative;display:inline-block;vertical-align:bottom;margin:6px 4px 6px 0;}
+  .msg-artifact-image{position:relative;display:inline-block;width:fit-content;max-width:min(360px,100%);vertical-align:bottom;margin:6px 4px 6px 0;line-height:0;}
   /* Generated images are the subject of the message, not a small attachment
      thumbnail — render them prominently at natural aspect ratio (the base
      .msg-media-img is a cropped 120x90 upload thumbnail). Capped + responsive so

From 07a50ebf3ba9231132ba839c33cb4eef236e7dfa Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Tue, 2 Jun 2026 00:09:32 +0000
Subject: [PATCH 09/10] polish(#3220): clarify download button on dark images
 (border + backdrop blur)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Nathan feedback: the download icon read as 'oddly placed' on dark generated
images. Geometry was already correct (8px inset on the image corner) — the
issue was low contrast: a flat rgba(0,0,0,.55) button blends into a dark image.
Add a subtle 1px white border, soft shadow, and 3px backdrop-blur so the button
reads as a clear chip-on-image regardless of the underlying pixels (the standard
treatment for on-image controls). CSS-only.
---
 static/style.css | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/static/style.css b/static/style.css
index e63e673b..8ad23043 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1572,9 +1572,9 @@
      they never overflow the message column or a mobile viewport. Lightbox-on-click
      (cursor:zoom-in) is preserved. */
   .msg-artifact-image .msg-media-img{margin:0;display:block;width:auto;height:auto;max-width:min(360px,100%);max-height:360px;object-fit:contain;border-radius:8px;cursor:zoom-in;}
-  .msg-artifact-download{position:absolute;top:8px;right:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(0,0,0,.55);color:#fff;text-decoration:none;opacity:0;transform:translateY(-2px);transition:opacity .12s,transform .12s,background .12s;pointer-events:none;}
+  .msg-artifact-download{position:absolute;top:8px;right:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(0,0,0,.62);border:1px solid rgba(255,255,255,.25);box-shadow:0 1px 4px rgba(0,0,0,.35);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);color:#fff;text-decoration:none;opacity:0;transform:translateY(-2px);transition:opacity .12s,transform .12s,background .12s;pointer-events:none;}
   .msg-artifact-image:hover .msg-artifact-download,.msg-artifact-image:focus-within .msg-artifact-download{opacity:1;transform:translateY(0);pointer-events:auto;}
-  .msg-artifact-download:hover{background:rgba(0,0,0,.78);}
+  .msg-artifact-download:hover{background:rgba(0,0,0,.82);border-color:rgba(255,255,255,.4);}
   .msg-artifact-download:focus-visible{opacity:1;pointer-events:auto;outline:2px solid var(--accent-bg-strong);outline-offset:1px;}
   @media (hover:none){.msg-artifact-download{opacity:1;pointer-events:auto;transform:none;}}
 

From 7f37fdb724233db5ace585d41bc8abf8ca38405a Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Tue, 2 Jun 2026 00:16:13 +0000
Subject: [PATCH 10/10] =?UTF-8?q?release:=20stamp=20v0.51.205=20(Release?=
 =?UTF-8?q?=20FY=20=E2=80=94=20stage-hi1)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f314419a..17658185 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +3,12 @@
 
 ## [Unreleased]
 
+## [v0.51.205] — 2026-06-01 — Release FY (stage-hi1 — workspace syntax highlighting + generated-image cards + manual title regeneration)
+
 ### Added
+- Workspace file previews now render with syntax highlighting via Prism.js (already loaded for chat code blocks), covering common languages (Python, JS/TS, CSS, JSON, SQL, shell, and more) and degrading gracefully to plain text for unknown/plain files and when offline. The preview code surface uses a single uniform background across light and dark themes (#3337, @mysoul12138).
 - Generated local image artifacts now render as a clean inline image (with click-to-zoom lightbox) plus a hover/focus-revealed **Download** action overlaid on the image, served through authenticated `/api/media` URLs — matching the common AI-chat pattern of letting the image be the hero rather than wrapping it in a permanent card (#3220, @AJV20).
-- The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. Closes #3106 (#3223, @AJV20).
+- The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. The menu was also streamlined to a compact icon + label layout (descriptions move to hover tooltips). Closes #3106 (#3223, @AJV20).
 
 ## [v0.51.204] — 2026-06-01 — Release FX (stage-batch17 — project/session operations honor the session's own profile)