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 `
, 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 `
`;
+ // 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 `
${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 `
${dlSvg}`;
+ return `
${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=
``
+ ``
+ ``
+ `${esc(label)}`
- + (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)