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:
nesquena-hermes authored and GitHub committed 2026-06-01 17:20:49 -07:00
commit 383e0501db
12 files changed
+525 -13

No files matched your search

+10 -1
View File
@@ -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.
---
---
+53
View File
@@ -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")
+29
View File
@@ -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.
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+7 -4
View File
@@ -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"
)
+36 -2
View File
@@ -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"