Merge pull request #3363 from nesquena/release/stage-hi1
Release FY — v0.51.205 (stage-hi1: workspace syntax highlighting + generated-image rendering + manual title regeneration)
This commit is contained in:
12 files changed
+525
-13
No files matched your search
+10
-1
@@ -3,6 +3,13 @@
|
||||
|
||||
## [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. 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)
|
||||
|
||||
### Fixed
|
||||
@@ -81,6 +88,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
|
||||
@@ -120,6 +128,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 <latest-tag>` 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
|
||||
@@ -7365,4 +7374,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.
|
||||
|
||||
---
|
||||
---
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -1292,6 +1297,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagram',
|
||||
media_video_label: 'Video',
|
||||
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',
|
||||
@@ -1747,6 +1753,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',
|
||||
@@ -2604,6 +2615,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagramma',
|
||||
media_video_label: 'Video',
|
||||
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',
|
||||
@@ -3059,6 +3071,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: '会話リンクをクリップボードにコピーしました',
|
||||
@@ -3921,6 +3938,7 @@ const LOCALES = {
|
||||
media_audio_label: '音声',
|
||||
media_svg_label: '図',
|
||||
media_video_label: '動画',
|
||||
media_download: 'ダウンロード',
|
||||
csv_loading: 'CSV を読み込み中',
|
||||
csv_too_large: 'CSV ファイルが大きすぎてインライン表示できません',
|
||||
csv_no_data: 'CSV ファイルのデータが不足しているためテーブル表示できません',
|
||||
@@ -4296,6 +4314,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: 'Ссылка на беседу скопирована в буфер обмена',
|
||||
@@ -5110,6 +5133,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Аудио',
|
||||
media_svg_label: 'Диаграмма',
|
||||
media_video_label: 'Видео',
|
||||
media_download: 'Скачать',
|
||||
csv_loading: 'Загрузка CSV',
|
||||
csv_too_large: 'CSV-файл слишком большой для встроенного отображения',
|
||||
csv_no_data: 'Недостаточно данных в CSV-файле',
|
||||
@@ -5525,6 +5549,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',
|
||||
@@ -6347,6 +6376,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagrama',
|
||||
media_video_label: 'Vídeo',
|
||||
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',
|
||||
@@ -6758,6 +6788,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',
|
||||
@@ -7598,6 +7633,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagramm',
|
||||
media_video_label: 'Video',
|
||||
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',
|
||||
@@ -8043,6 +8079,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: '对话链接已复制到剪贴板',
|
||||
@@ -8854,6 +8895,7 @@ const LOCALES = {
|
||||
media_audio_label: '音频',
|
||||
media_svg_label: '图表',
|
||||
media_video_label: '视频',
|
||||
media_download: '下载',
|
||||
csv_loading: '加载 CSV',
|
||||
csv_too_large: 'CSV 文件过大,无法内联渲染',
|
||||
csv_no_data: 'CSV 文件数据不足,无法渲染为表格',
|
||||
@@ -9191,6 +9233,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: '對話連結已複製到剪貼簿',
|
||||
@@ -10163,6 +10210,7 @@ const LOCALES = {
|
||||
media_audio_label: '音訊',
|
||||
media_svg_label: '圖表',
|
||||
media_video_label: '影片',
|
||||
media_download: '下載',
|
||||
csv_loading: '載入 CSV',
|
||||
csv_too_large: 'CSV 檔案過大,無法內嵌渲染',
|
||||
csv_no_data: 'CSV 檔案資料不足,無法渲染為表格',
|
||||
@@ -10582,6 +10630,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',
|
||||
@@ -11792,6 +11845,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: '대화 링크가 클립보드에 복사되었습니다',
|
||||
@@ -12607,6 +12665,7 @@ const LOCALES = {
|
||||
media_audio_label: '오디오',
|
||||
media_svg_label: '다이어그램',
|
||||
media_video_label: '비디오',
|
||||
media_download: '다운로드',
|
||||
csv_loading: 'CSV 로딩 중',
|
||||
csv_too_large: 'CSV 파일이 너무 커서 인라인 렌더링할 수 없습니다',
|
||||
csv_no_data: 'CSV 파일에 표시할 데이터가 부족합니다',
|
||||
@@ -13032,6 +13091,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',
|
||||
@@ -13822,6 +13886,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Audio',
|
||||
media_svg_label: 'Diagramme',
|
||||
media_video_label: 'Vidéo',
|
||||
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',
|
||||
@@ -14352,6 +14417,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ı',
|
||||
@@ -15165,6 +15235,7 @@ const LOCALES = {
|
||||
media_audio_label: 'Ses',
|
||||
media_svg_label: 'Diyagram',
|
||||
media_video_label: 'Video',
|
||||
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',
|
||||
|
||||
+58
-3
@@ -10,6 +10,7 @@ const ICONS={
|
||||
trash:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 4.5h9M6.5 4.5V3h3v1.5M4.5 4.5v8.5h7v-8.5"/><line x1="7" y1="7" x2="7" y2="11"/><line x1="9" y1="7" x2="9" y2="11"/></svg>',
|
||||
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
|
||||
edit:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 2.5l2 2L5 13H3v-2z"/><path d="M10 4l2 2"/></svg>',
|
||||
spark:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.8l1.1 3.1 3.1 1.1-3.1 1.1L8 10.2 6.9 7.1 3.8 6l3.1-1.1z"/><path d="M12.5 9.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5z"/></svg>',
|
||||
link:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M6.7 9.3a3 3 0 0 1 0-4.2l1.7-1.7a3 3 0 0 1 4.2 4.2l-1 1"/><path d="M9.3 6.7a3 3 0 0 1 0 4.2l-1.7 1.7a3 3 0 0 1-4.2-4.2l1-1"/></svg>',
|
||||
};
|
||||
|
||||
@@ -2031,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<margin) top=margin;
|
||||
}
|
||||
_sessionActionMenu.style.top=top+'px';
|
||||
}
|
||||
|
||||
@@ -2044,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=
|
||||
`<span class="ws-opt-action">`
|
||||
+ `<span class="ws-opt-icon">${icon}</span>`
|
||||
+ `<span class="session-action-copy">`
|
||||
+ `<span class="ws-opt-name">${esc(label)}</span>`
|
||||
+ (meta?`<span class="session-action-meta">${esc(meta)}</span>`:'')
|
||||
+ `</span>`
|
||||
+ `</span>`;
|
||||
opt.onclick=async(e)=>{
|
||||
@@ -2304,6 +2326,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(
|
||||
|
||||
+25
-1
@@ -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;}
|
||||
@@ -1561,6 +1561,22 @@
|
||||
.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);}
|
||||
/* Generated local image artifact (#3220): clean inline image with a
|
||||
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;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
|
||||
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,.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,.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;}}
|
||||
|
||||
/* ── 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);}
|
||||
@@ -1957,6 +1973,14 @@
|
||||
.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);}
|
||||
/* Workspace preview syntax highlighting (#3337): Prism's prism-tomorrow theme
|
||||
styles BOTH pre[class*="language-"] and code[class*="language-"] with its own
|
||||
gray background + padding. Prism propagates the language-* class onto the
|
||||
<pre> (.preview-code), so without overriding the <pre> too you get a gray
|
||||
prism frame around the navy var(--code-bg) <code> — 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);}
|
||||
|
||||
+14
-1
@@ -3504,6 +3504,19 @@ function renderMd(raw){
|
||||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
|
||||
};
|
||||
const localArtifactCard=(src,name)=>{
|
||||
const safeSrc=esc(src);
|
||||
const safeName=esc(name||'image');
|
||||
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='<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
||||
return `<span class="msg-artifact-image"><img class="msg-media-img" src="${safeSrc}" alt="${safeName}" loading="lazy"><a class="msg-artifact-download" href="${safeSrc}" download="${safeName}" title="${dlLabel}" aria-label="${dlLabel}" onclick="event.stopPropagation()">${dlSvg}</a></span>`;
|
||||
};
|
||||
if(/^file:\/\//i.test(ref)){
|
||||
try{
|
||||
const u=new URL(ref);
|
||||
@@ -3545,7 +3558,7 @@ function renderMd(raw){
|
||||
const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
|
||||
const localKind=mediaKindForName(ref);
|
||||
if(localKind==='image'){
|
||||
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
|
||||
return localArtifactCard(apiUrl,ref.split('/').pop()||'image');
|
||||
}
|
||||
// SVG → inline image (no download, render directly)
|
||||
if(_SVG_EXTS.test(ref)){
|
||||
|
||||
+46
-1
@@ -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,26 @@ 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='';
|
||||
// Prism.highlightElement() propagates the language-* class onto the
|
||||
// parent <pre>, so a previously-previewed code file leaves e.g.
|
||||
// "language-css" on #previewCode. A subsequent plain-text file builds a
|
||||
// class-less <code>, and Prism walks up to that stale ancestor class and
|
||||
// mis-highlights prose. Strip any inherited language-* token from the
|
||||
// <pre> before each render so highlighting never leaks across files.
|
||||
pre.className=pre.className.replace(/\blanguage-\S+/g,'').replace(/\s+/g,' ').trim();
|
||||
pre.appendChild(codeEl);
|
||||
// Only invoke Prism when we actually assigned a language; otherwise the
|
||||
// class-less <code> would inherit any ancestor language-* class.
|
||||
if(lang&&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);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -76,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
|
||||
|
||||
@@ -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 <pre>, 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 <pre> 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 <code> never walks up to an ancestor class.
|
||||
"""
|
||||
body = _open_file_body()
|
||||
# Guard 1: stale language-* token stripped from the <pre> before append.
|
||||
assert "pre.className=pre.className.replace(/\\blanguage-\\S+/g,'')" in body, (
|
||||
"previewCode <pre> 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"
|
||||
)
|
||||
@@ -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,23 @@ 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_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-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("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)
|
||||
|
||||
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 +143,24 @@ 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_image_css_defined(self):
|
||||
# #3220 redesign: clean image + hover-revealed download overlay.
|
||||
for cls in [
|
||||
".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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user