diff --git a/.gitignore b/.gitignore
index ab6ed33c1..adb10eaa8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,6 +52,9 @@ docs/AGENTS.md
docs/CLAUDE.md
docs/.cursorrules
docs/.windsurfrules
+.specify/
+AGENT_TRACE.yaml
+EXECUTION_LOG.md
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc44d97eb..db38aab70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,11 @@
## [Unreleased]
+### Added
+
+- **Transparent Stream activity display mode.** Settings → Appearance now includes an opt-in chat activity display mode that renders each Thinking/tool event as its own chronological expandable row, with live tool completion updating the same row and settled/reloaded transcripts preserving the same event shape. Compact Worklog remains the default. (#3820)
+- **Transparent Stream turn-level collapse + old-event fading + per-turn footer.** In Transparent Stream, clicking the assistant role label (the Hermes chat name tag) collapses the entire event stack underneath into a compact "output only" view with an animated chevron. Older event rows fade progressively from medium to low opacity (steps of ~0.18, floor at 0.32) so the eye lands on the most recent activity; hover restores full opacity. A bottom-of-turn footer mirrors the live run-status line for settled turns, showing elapsed time, first-token time (TTFT), token usage, and final status. (#3820)
+
## [v0.51.359] — 2026-06-11 — Release LW (assistant turn anchor phase 0 scaffold)
### Added
diff --git a/api/config.py b/api/config.py
index a12c3ef18..150b2ce4b 100644
--- a/api/config.py
+++ b/api/config.py
@@ -5746,6 +5746,7 @@ _SETTINGS_DEFAULTS = {
"font_size": "default", # small | default | large | xlarge
"session_jump_buttons": False, # show Start/End transcript jump pills
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
+ "chat_activity_display_mode": "compact_worklog", # compact_worklog | transparent_stream
"worklog_details_expanded_default": False, # opt-in: expand Worklog details by default; default remains folded
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
"inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage
@@ -5900,6 +5901,7 @@ _SETTINGS_ENUM_VALUES = {
"font_size": {"small", "default", "large", "xlarge"},
"auto_title_refresh_every": {"0", "5", "10", "20"},
"busy_input_mode": {"queue", "interrupt", "steer"},
+ "chat_activity_display_mode": {"compact_worklog", "transparent_stream"},
}
_SETTINGS_INT_RANGES = {
"pinned_sessions_limit": (1, 99),
diff --git a/static/boot.js b/static/boot.js
index 8fff26ed7..3c93611ea 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -1798,6 +1798,8 @@ function applyBotName(){
window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled;
window._showThinking=s.show_thinking!==false;
window._simplifiedToolCalling=true;
+ window._chatActivityDisplayMode=s.chat_activity_display_mode==='transparent_stream'?'transparent_stream':'compact_worklog';
+ window._transparentStream=window._chatActivityDisplayMode==='transparent_stream';
window._terminalAutoExpandOnOutput=!!s.terminal_auto_expand_on_output;
window._worklogDetailsExpandedByDefault=!!(
Object.prototype.hasOwnProperty.call(s,'worklog_details_expanded_default')
@@ -1903,6 +1905,8 @@ function applyBotName(){
window._whatsNewSummaryEnabled=false;
window._showThinking=true;
window._simplifiedToolCalling=true;
+ window._chatActivityDisplayMode='compact_worklog';
+ window._transparentStream=false;
window._terminalAutoExpandOnOutput=false;
window._sessionJumpButtonsEnabled=false;
window._sidebarDensity='compact';
diff --git a/static/i18n.js b/static/i18n.js
index cbb3e6ed3..9836c6709 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -560,6 +560,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Sidebar tabs',
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.',
@@ -1970,6 +1976,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Se abilitato, i messaggi precedenti si caricano automaticamente scorrendo in alto. Se disabilitato, usa il pulsante messaggi precedenti.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Schede della barra laterale',
settings_desc_tab_visibility: 'Scegli quali schede mostrare nella barra laterale e nel rail. Chat e Impostazioni sono sempre visibili.',
@@ -3371,6 +3383,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
settings_label_worklog_details_expanded_default: 'Worklog の詳細を自動的に開く',
settings_desc_worklog_details_expanded_default: '有効にすると、新しい Worklog の詳細が展開された状態で始まり、ツール、Thinking、進捗カードをクリックなしで確認できます。無効の場合、Worklog の詳細はデフォルトで折りたたまれます。ターンごとの手動折りたたみ/展開は優先されます。',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'サイドバータブ',
settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。',
@@ -5391,6 +5409,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Вкладки боковой панели',
settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.',
@@ -6717,6 +6741,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Pestañas de la barra lateral',
settings_desc_tab_visibility: 'Elige qué pestañas aparecen en la barra lateral y el rail. Chat y Configuración siempre están visibles.',
@@ -7743,6 +7773,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Seitenleiste-Tabs',
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Ziehe die Chips, um die Reihenfolge zu ändern. Chat und Einstellungen sind immer sichtbar.',
@@ -9404,6 +9440,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: '侧边栏标签',
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
@@ -10125,6 +10167,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: '側邊欄標籤',
settings_desc_tab_visibility: '選擇在側邊欄和導覽列中顯示哪些標籤。聊天和設定始終可見。',
@@ -11416,6 +11464,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Abas da barra lateral',
settings_desc_tab_visibility: 'Escolha quais abas aparecem na barra lateral e no rail. Chat e Configurações estão sempre visíveis.',
@@ -12718,6 +12772,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: '사이드바 탭',
settings_desc_tab_visibility: '사이드바와 레일에 표시할 탭을 선택하세요. 채팅과 설정은 항상 표시됩니다.',
@@ -14037,6 +14097,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Onglets de la barre latérale',
settings_desc_tab_visibility: 'Choisissez quels onglets apparaissent dans la barre latérale et le rail. Chat et Paramètres sont toujours visibles.',
@@ -15457,6 +15523,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Etkinleştirildiğinde, yukarı doğru kaydırdığınızda eski mesajlar otomatik olarak yüklenir. Devre dışı bırakıldığında eski mesajlar düğmesini kullanın.',
settings_label_worklog_details_expanded_default: 'Open Worklog details automatically',
settings_desc_worklog_details_expanded_default: 'When enabled, new Worklog details start expanded so tool, thinking, and progress cards are visible without an extra click. When off, Worklog details stay folded by default; per-turn manual collapse/expand choices still win.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Kenar çubuğu sekmeleri',
settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.',
@@ -16869,6 +16941,12 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Gdy ta opcja jest włączona, starsze wiadomości ładują się automatycznie przy przewijaniu w górę. Gdy jest wyłączona, użyj przycisku wczytywania starszych wiadomości.',
settings_label_worklog_details_expanded_default: 'Otwieraj szczegóły Worklog automatycznie',
settings_desc_worklog_details_expanded_default: 'Gdy ta opcja jest włączona, nowe szczegóły Worklog zaczynają rozwinięte, dzięki czemu karty narzędzi, Thinking i postępu są widoczne bez dodatkowego kliknięcia. Gdy jest wyłączona, szczegóły Worklog pozostają domyślnie zwinięte. Ręczne decyzje o zwinięciu/rozwinięciu w danej turze mają pierwszeństwo.',
+ settings_label_chat_activity_display_mode: 'Activity display',
+ settings_desc_chat_activity_display_mode: 'Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.',
+ first_token_time: 'Time to first token',
+ done: 'Done',
+ settings_option_compact_worklog: 'Compact Worklog',
+ settings_option_transparent_stream: 'Transparent Stream',
settings_label_tab_visibility: 'Karty paska bocznego',
settings_desc_tab_visibility: 'Wybierz, które karty pojawiają się na pasku bocznym i szynie. Przeciągnij elementy, aby zmienić ich kolejność. Czat i Ustawienia są zawsze widoczne.',
diff --git a/static/icons.js b/static/icons.js
index 6aedb4590..c29776f73 100644
--- a/static/icons.js
+++ b/static/icons.js
@@ -44,6 +44,7 @@ const LI_PATHS = {
'globe': ' ',
'play': ' ',
'wrench': ' ',
+ 'plug': ' ',
'brain': ' ',
'sparkles': ' ',
'book-open': ' ',
diff --git a/static/index.html b/static/index.html
index 373ea3427..ffbc49116 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1004,6 +1004,22 @@
When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.
+
+
Activity display
+
+
+ Compact Worklog
+
+
+ Transparent Stream
+
+
+
+ Compact Worklog
+ Transparent Stream
+
+
Compact Worklog groups supporting activity into a quiet summary. Transparent Stream keeps each thinking/tool event as its own chronological expandable row.
+
diff --git a/static/messages.js b/static/messages.js
index 89cec5c2e..34e7dcffb 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -1891,7 +1891,6 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _STREAM_FADE_STAGGER_MS=16;
const _STREAM_FADE_DONE_MAX_MS=320;
const _STREAM_FADE_DONE_DRAIN_MAX_MS=900;
- const _streamFadeEnabledForStream=window._fadeTextEffect===true;
function _mergeSettledToolCallsWithLiveMetadata(rawCalls){
const liveCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
@@ -2116,7 +2115,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_renderPending=false;
}
function _shouldUseStreamFade(){
- return _streamFadeEnabledForStream;
+ return window._fadeTextEffect===true;
}
function _streamFadeSkipNode(node){
if(!node||node.nodeType!==1) return false;
diff --git a/static/panels.js b/static/panels.js
index 75f77605c..9ec3c586c 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -6204,10 +6204,12 @@ function _applyTtsEnabled(enabled){
function _appearancePayloadFromUi(){
const worklogDetailsExpanded=!!($('settingsWorklogDetailsExpandedDefault')||{}).checked;
+ const chatActivityModeSel=$('settingsChatActivityDisplayMode');
return {
theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark',
skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default',
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
+ chat_activity_display_mode: chatActivityModeSel&&chatActivityModeSel.value==='transparent_stream'?'transparent_stream':'compact_worklog',
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
worklog_details_expanded_default: worklogDetailsExpanded,
@@ -6217,6 +6219,27 @@ function _appearancePayloadFromUi(){
};
}
+function _syncChatActivityDisplayModeControl(mode){
+ const next=mode==='transparent_stream'?'transparent_stream':'compact_worklog';
+ const select=$('settingsChatActivityDisplayMode');
+ if(select) select.value=next;
+ document.querySelectorAll('[data-chat-activity-mode]').forEach(btn=>{
+ const active=btn.getAttribute('data-chat-activity-mode')===next;
+ btn.classList.toggle('active',active);
+ btn.setAttribute('aria-pressed',active?'true':'false');
+ });
+ window._chatActivityDisplayMode=next;
+ window._transparentStream=next==='transparent_stream';
+}
+
+function _pickChatActivityDisplayMode(mode){
+ _syncChatActivityDisplayModeControl(mode);
+ if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
+ if(typeof renderMessages==='function') renderMessages({preserveScroll:true});
+ _scheduleAppearanceAutosave();
+}
+if(typeof window!=='undefined') window._pickChatActivityDisplayMode=_pickChatActivityDisplayMode;
+
function _setAppearanceAutosaveStatus(state){
const el=$('settingsAppearanceAutosaveStatus');
if(!el) return;
@@ -6263,6 +6286,11 @@ async function _autosaveAppearanceSettings(payload){
}
if(saved){
window._sessionJumpButtonsEnabled=!!saved.session_jump_buttons;
+ if(Object.prototype.hasOwnProperty.call(saved,'chat_activity_display_mode')){
+ _syncChatActivityDisplayModeControl(saved.chat_activity_display_mode);
+ if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
+ if(typeof renderMessages==='function') renderMessages({preserveScroll:true});
+ }
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
}
window._sessionEndlessScrollEnabled=!!(saved&&saved.session_endless_scroll);
@@ -6501,6 +6529,13 @@ async function loadSettingsPanel(){
};
}
const worklogDetailsExpandedCb=$('settingsWorklogDetailsExpandedDefault');
+ const chatActivityModeSel=$('settingsChatActivityDisplayMode');
+ if(chatActivityModeSel){
+ _syncChatActivityDisplayModeControl(settings.chat_activity_display_mode);
+ chatActivityModeSel.addEventListener('change',()=>{
+ _pickChatActivityDisplayMode(chatActivityModeSel.value);
+ },{once:false});
+ }
if(worklogDetailsExpandedCb){
const worklogDetailsExpanded=Object.prototype.hasOwnProperty.call(settings,'worklog_details_expanded_default')
? settings.worklog_details_expanded_default
@@ -6648,7 +6683,14 @@ async function loadSettingsPanel(){
pinnedLimitField.addEventListener('input',()=>{window._pinnedSessionsLimit=parseInt(pinnedLimitField.value,10)||3;_schedulePreferencesAutosave();},{once:false});
}
const fadeTextCb=$('settingsFadeTextEffect');
- if(fadeTextCb){fadeTextCb.checked=!!settings.fade_text_effect;window._fadeTextEffect=fadeTextCb.checked;fadeTextCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
+ if(fadeTextCb){
+ fadeTextCb.checked=!!settings.fade_text_effect;
+ window._fadeTextEffect=fadeTextCb.checked;
+ fadeTextCb.addEventListener('change',()=>{
+ window._fadeTextEffect=fadeTextCb.checked;
+ _schedulePreferencesAutosave();
+ },{once:false});
+ }
const terminalAutoExpandCb=$('settingsTerminalAutoExpand');
if(terminalAutoExpandCb){terminalAutoExpandCb.checked=!!settings.terminal_auto_expand_on_output;window._terminalAutoExpandOnOutput=terminalAutoExpandCb.checked;terminalAutoExpandCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const apiRedactCb=$('settingsApiRedact');
@@ -7763,6 +7805,7 @@ function _applySavedSettingsUi(saved, body, opts){
window._whatsNewSummaryEnabled=!!body.whats_new_summary_enabled;
window._showThinking=body.show_thinking!==false;
window._simplifiedToolCalling=true;
+ _syncChatActivityDisplayModeControl(body.chat_activity_display_mode);
window._terminalAutoExpandOnOutput=!!body.terminal_auto_expand_on_output;
window._sessionJumpButtonsEnabled=!!body.session_jump_buttons;
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
@@ -8100,6 +8143,7 @@ async function saveSettings(andClose){
body.font_size=fontSize;
body.session_jump_buttons=!!($('settingsSessionJumpButtons')||{}).checked;
body.session_endless_scroll=!!($('settingsSessionEndlessScroll')||{}).checked;
+ body.chat_activity_display_mode=(($('settingsChatActivityDisplayMode')||{}).value==='transparent_stream')?'transparent_stream':'compact_worklog';
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_quota_chip=showQuotaChip===true;
diff --git a/static/style.css b/static/style.css
index cbf76a6eb..b4451f8fb 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3136,6 +3136,297 @@ body.resizing .sidebar{transition:none!important;}
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--message-code-font-size);white-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;}
.tool-card-result pre{font-size:var(--message-code-font-size);color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;}
+/* ── Transparent Stream event rows ── */
+/* Box rhythm: each event is a compact rounded card with a thin, transparent
+ border, a 2px type rail, and tight spacing. Keep the newest activity easy
+ to scan without letting the execution trace overpower the final answer. */
+.transparent-event-controls{
+ display:flex;
+ align-items:center;
+ gap:6px;
+ margin:1px 0 3px var(--msg-rail);
+ max-width:var(--msg-max);
+ color:var(--muted);
+ font-size:11px;
+ line-height:1.3;
+}
+.transparent-event-controls-label{font-weight:600;color:var(--muted);}
+.transparent-event-action,
+.transparent-event-control{
+ cursor:pointer;
+ color:var(--muted);
+ opacity:.78;
+ text-decoration:underline;
+ text-decoration-color:transparent;
+ text-underline-offset:3px;
+}
+.transparent-event-action:hover,
+.transparent-event-control:hover{color:var(--text);opacity:1;text-decoration-color:currentColor;}
+.transparent-event-row{
+ margin:1px 0 1px var(--msg-rail);
+ max-width:var(--msg-max);
+ width:calc(100% - var(--msg-rail));
+ border:1px solid color-mix(in srgb,var(--accent) 12%,var(--border-subtle));
+ border-left:2px solid color-mix(in srgb,var(--accent) 42%,transparent);
+ border-radius:12px;
+ background:linear-gradient(90deg,color-mix(in srgb,var(--accent-bg) 22%,transparent),color-mix(in srgb,var(--surface) 88%,transparent) 34%);
+ overflow:hidden;
+ box-shadow:0 1px 0 rgba(0,0,0,.03);
+ animation:transparent-event-enter .18s ease-out both;
+ transition:border-color .22s ease,box-shadow .22s ease,opacity .22s ease;
+}
+.transparent-event-row[data-event-type="thinking"]{
+ background:linear-gradient(90deg,color-mix(in srgb,var(--accent-bg-strong) 30%,transparent),color-mix(in srgb,var(--accent-bg) 28%,color-mix(in srgb,var(--surface) 88%,transparent)) 42%);
+ border-color:color-mix(in srgb,var(--accent) 18%,var(--border-subtle));
+ border-left-color:color-mix(in srgb,var(--accent) 58%,transparent);
+}
+.transparent-event-row[data-expanded="1"]{
+ border-color:color-mix(in srgb,var(--accent) 28%,var(--border-subtle));
+ box-shadow:0 4px 14px rgba(0,0,0,.06);
+}
+.transparent-event-row:hover{border-color:color-mix(in srgb,var(--accent) 38%,var(--border-subtle));}
+.transparent-event-row .tool-card{
+ border:0;
+ border-radius:0;
+ background:transparent;
+ overflow:visible;
+}
+.transparent-event-row .tool-card-header,
+.transparent-event-row .thinking-card-header{
+ display:flex;
+ align-items:center;
+ gap:5px;
+ min-height:20px;
+ padding:2px 4px 2px 8px;
+ margin-left:0;
+ border-radius:0;
+ color:var(--muted);
+ transition:background .18s ease,color .18s ease;
+}
+.transparent-event-row .tool-card-header:hover,
+.transparent-event-row .thinking-card-header:hover{background:var(--hover-bg);color:var(--text);}
+.transparent-event-row .tool-card-icon,
+.transparent-event-row .thinking-card-icon{
+ display:inline-flex;
+ align-items:center;
+ justify-content:center;
+ width:14px;height:14px;
+ flex-shrink:0;
+ order:1;
+ opacity:.85;
+}
+.transparent-event-row .tool-card-icon svg,
+.transparent-event-row .thinking-card-icon svg{width:11px;height:11px;}
+.transparent-event-row .tool-card-name{
+ font-weight:600;
+ color:var(--text);
+ opacity:.78;
+ font-size:11.5px;
+ order:2;
+ flex-shrink:0;
+ max-width:40%;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+}
+.transparent-event-row .tool-card-title,
+.transparent-event-row .tool-card-preview,
+.transparent-event-row .thinking-card-label,
+.transparent-event-thinking-preview{
+ display:inline;
+ min-width:0;
+ flex:1 1 auto;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+ color:var(--muted);
+ opacity:.74;
+ font-size:11.5px;
+ order:3;
+}
+.transparent-event-status{
+ flex-shrink:0;
+ order:4;
+ color:var(--muted);
+ opacity:.7;
+ font-size:10px;
+ font-weight:500;
+ letter-spacing:.02em;
+ padding:1px 5px;
+ border-radius:6px;
+ background:color-mix(in srgb,var(--accent) 8%,transparent);
+}
+.transparent-event-copy{
+ border:0;
+ background:transparent;
+ color:var(--muted);
+ opacity:.5;
+ padding:1px 2px;
+ cursor:pointer;
+ flex-shrink:0;
+ order:9;
+ display:inline-flex;
+ align-items:center;
+ justify-content:center;
+ border-radius:4px;
+ transition:opacity .18s ease,color .18s ease,background .18s ease;
+}
+.transparent-event-copy:hover{opacity:1;color:var(--text);background:color-mix(in srgb,var(--hover-bg) 60%,transparent);}
+.transparent-event-row .tool-card-toggle,
+.transparent-event-row .thinking-card-toggle{
+ order:10;
+ flex-shrink:0;
+ display:inline-flex;
+ align-items:center;
+ justify-content:center;
+ width:14px;height:14px;
+ opacity:.55;
+ transition:opacity .18s ease,transform .22s ease;
+}
+.transparent-event-row .tool-card:hover .tool-card-toggle,
+.transparent-event-row .thinking-card:hover .thinking-card-toggle{opacity:1;}
+.transparent-event-row .tool-card.open .tool-card-toggle,
+.transparent-event-row .thinking-card.open .thinking-card-toggle{transform:rotate(90deg);opacity:.9;}
+.transparent-event-progress{
+ position:relative;
+ height:3px;
+ background:linear-gradient(180deg,color-mix(in srgb,var(--border-subtle) 100%,transparent) 0,color-mix(in srgb,var(--border-subtle) 40%,transparent) 100%);
+ border-radius:0 0 12px 12px;
+ overflow:hidden;
+ margin:0 -1px -1px -1px;
+}
+.transparent-event-progress::before{
+ content:"";
+ position:absolute;left:0;top:0;bottom:0;
+ width:0%;
+ background:linear-gradient(90deg,color-mix(in srgb,var(--accent) 70%,transparent),color-mix(in srgb,var(--accent) 95%,transparent) 50%,color-mix(in srgb,var(--accent) 70%,transparent) 100%);
+ box-shadow:0 0 4px color-mix(in srgb,var(--accent) 60%,transparent);
+ border-radius:inherit;
+ transition:width .35s cubic-bezier(.4,0,.2,1);
+}
+.transparent-event-progress[data-progress-running="1"]::before{animation:transparent-progress-shimmer 1.6s ease-in-out infinite;}
+.transparent-event-progress[data-progress-percent]::before{width:var(--transparent-progress-percent,0%);}
+.transparent-event-progress::after{
+ content:"";
+ position:absolute;left:0;right:0;top:0;height:1px;
+ background:linear-gradient(90deg,transparent 0,color-mix(in srgb,var(--surface) 70%,transparent) 20%,color-mix(in srgb,var(--surface) 90%,transparent) 50%,color-mix(in srgb,var(--surface) 70%,transparent) 80%,transparent 100%);
+ pointer-events:none;
+}
+@keyframes transparent-progress-shimmer{
+ 0%{left:0;right:100%;}
+ 100%{left:100%;right:0;}
+}
+.transparent-event-row + .transparent-event-row::before{
+ content:"";
+ display:block;
+ height:1px;
+ margin:0 12px;
+ background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 18%,transparent) 20%,color-mix(in srgb,var(--accent) 28%,transparent) 50%,color-mix(in srgb,var(--accent) 18%,transparent) 80%,transparent);
+ border-radius:1px;
+}
+.transparent-detail-modes{
+ display:flex;
+ gap:10px;
+ padding:4px 8px 0;
+ color:var(--muted);
+ font-size:10.5px;
+}
+.transparent-detail-mode{cursor:pointer;opacity:.72;transition:opacity .18s ease,color .18s ease;}
+.transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;}
+.transparent-event-row .tool-card-detail,
+.transparent-event-row .thinking-card-body{
+ display:block;
+ max-height:0;
+ opacity:0;
+ overflow:hidden;
+ padding:0;
+ margin:0;
+ border-top:1px solid transparent;
+ background:transparent;
+ transform:translateY(-1px);
+ transition:max-height .28s cubic-bezier(.4,0,.2,1),opacity .2s ease,transform .24s ease,border-top-color .2s ease,padding .22s ease;
+}
+.transparent-event-row .tool-card.open .tool-card-detail,
+.transparent-event-row .thinking-card.open .thinking-card-body{
+ max-height:520px;
+ opacity:1;
+ transform:translateY(0);
+ border-top-color:var(--border-subtle);
+ padding:2px 0 4px;
+ overflow:auto;
+}
+.transparent-event-row .tool-card-detail > *,
+.transparent-event-row .thinking-card-body > *{min-height:0;}
+.transparent-event-row .tool-card-args,
+.transparent-event-row .tool-card-result,
+.transparent-event-row .thinking-card-body pre{padding:6px 8px;}
+.transparent-event-row .tool-card-detail[data-transparent-detail-mode="output"] .tool-card-args{display:none;}
+.transparent-event-row .tool-card-args{margin-bottom:0;}
+.transparent-event-row .tool-arg-pair{display:flex;flex-direction:column;gap:1px;padding:3px 0;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 72%,transparent);}
+.transparent-event-row .tool-arg-pair:last-child{border-bottom:0;}
+.transparent-event-row .tool-arg-key{color:var(--muted);font-size:10px;font-family:var(--font-mono);display:block;margin-bottom:0;letter-spacing:.01em;}
+.transparent-event-row .tool-arg-val{font-family:var(--font-mono);font-size:var(--message-code-font-size);color:var(--text);line-height:1.5;}
+.transparent-event-row .tool-card-result pre,
+.transparent-event-row .thinking-card-body pre{
+ background:var(--code-bg);
+ border:1px solid var(--border-subtle);
+ border-radius:8px;
+ padding:6px 8px;
+ color:var(--muted);
+ line-height:1.5;
+}
+@media(max-width:600px){
+ .transparent-event-controls,
+ .transparent-event-row{margin-left:0;width:100%;max-width:none;}
+}
+@keyframes transparent-event-enter{
+ from{opacity:0;transform:translateY(2px);}
+ to{opacity:1;transform:translateY(0);}
+}
+
+/* ── Old-event fading (transparent mode: medium → low) ─────────────── */
+.transparent-event-row[data-transparent-fade="1"]{opacity:.82;}
+.transparent-event-row[data-transparent-fade="2"]{opacity:.66;}
+.transparent-event-row[data-transparent-fade="3"]{opacity:.52;}
+.transparent-event-row[data-transparent-fade="4"]{opacity:.4;}
+.transparent-event-row[data-transparent-fade="5"]{opacity:.32;}
+.transparent-event-row[data-transparent-fade]{transition:opacity .22s ease;}
+.transparent-event-row[data-transparent-fade]:hover{opacity:1;}
+
+/* ── Collapsible Hermes chat name tag ───────── */
+.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-event-row,
+.assistant-turn[data-transparent-turn-collapsed="1"] .transparent-event-controls{
+ display:none;
+}
+.assistant-turn .msg-role.assistant{cursor:pointer;user-select:none;}
+.assistant-turn .msg-role.assistant .transparent-turn-chevron{
+ display:inline-block;margin-left:6px;opacity:.6;
+ transition:transform .2s ease;
+}
+.assistant-turn[data-transparent-turn-collapsed="1"] .msg-role.assistant .transparent-turn-chevron{
+ transform:rotate(-90deg);
+}
+
+/* ── Turn footer (transparent mode: elapsed · tokens · TTFT · status) ─ */
+/* Mirrors the live run-status line for settled turns in transparent
+ mode. Shows duration, first-token time, token usage, and final
+ status. Lives OUTSIDE assistant-turn-blocks so it stays visible even
+ when the event rows are collapsed. Tight offset and small font to
+ keep the visual rhythm with the event cards above it. */
+.transparent-turn-footer{
+ display:flex;align-items:center;flex-wrap:wrap;gap:6px;
+ margin:2px 0 2px var(--msg-rail);
+ max-width:var(--msg-max);
+ font-size:11.5px;color:var(--muted);line-height:1.3;
+ font-variant-numeric:tabular-nums;
+ padding:2px 4px;
+}
+.transparent-turn-footer .lf-time{color:var(--text);}
+.transparent-turn-footer .lf-sep{opacity:.4;}
+.transparent-turn-footer .lf-status{color:var(--muted);opacity:.85;}
+.transparent-turn-footer .lf-ttft{color:var(--accent);opacity:.9;}
+.transparent-turn-footer .lf-tokens{font-variant-numeric:tabular-nums;}
+
/* ── Manual compression cards (transient transcript-local feedback) ── */
.live-compression-cards{
display:none;
@@ -3799,6 +4090,10 @@ main.main > #mainPlugin{display:none;}
#mainSettings .theme-pick-btn.active,
#mainSettings .skin-pick-btn.active,
#mainSettings .font-size-pick-btn.active{border-color:var(--accent)!important;box-shadow:0 0 0 1px var(--accent-bg-strong)!important;background:var(--surface)!important;}
+#mainSettings .chat-activity-mode-toggle{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:4px;}
+#mainSettings .chat-activity-mode-btn{min-height:34px;padding:7px 10px;border:1px solid var(--border2);border-radius:8px;background:var(--surface);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;}
+#mainSettings .chat-activity-mode-btn:hover{border-color:var(--accent-bg-strong);color:var(--text);background:var(--hover-bg);}
+#mainSettings .chat-activity-mode-btn.active{border-color:var(--accent);color:var(--accent-text);background:var(--accent-bg);box-shadow:0 0 0 1px var(--accent-bg-strong);}
/* Responsive: tighten canvas on small screens. */
@media (max-width: 768px){
@@ -3809,6 +4104,7 @@ main.main > #mainPlugin{display:none;}
.settings-version-badge{white-space:nowrap;}
.hermes-action-grid{grid-template-columns:1fr;}
#mainSettings .settings-field{padding:14px;}
+ #mainSettings .chat-activity-mode-toggle{grid-template-columns:1fr;}
}
/* ── Provider cards (Settings → Providers) ──
diff --git a/static/ui.js b/static/ui.js
index 7c9d0132f..87b8653f7 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -2655,6 +2655,12 @@ function _formatTurnDuration(seconds){
if(h)return`${h}h ${m}m`;
return`${m}m ${s}s`;
}
+function _formatFirstToken(ms){
+ const n=Number(ms);
+ if(!Number.isFinite(n)||n<0)return'';
+ if(n<1000)return`${Math.round(n)}ms`;
+ return`${(n/1000).toFixed(2)}s`;
+}
function _formatActiveElapsedTimer(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
@@ -6562,6 +6568,499 @@ function _thinkingActivityNode(text, open){
_renderThinkingInto(row,text);
return row;
}
+function chatActivityMode(){
+ if(typeof window==='undefined') return 'compact_worklog';
+ return window._chatActivityDisplayMode || (window._transparentStream ? 'transparent_stream' : 'compact_worklog') || 'compact_worklog';
+}
+function isTransparentStream(){
+ return chatActivityMode()==='transparent_stream';
+}
+function isCompactWorklogMode(){
+ return isSimplifiedToolCalling()&&!isTransparentStream();
+}
+if(typeof window!=='undefined'){
+ window.chatActivityMode=chatActivityMode;
+ window.isTransparentStream=isTransparentStream;
+ window.isCompactWorklogMode=isCompactWorklogMode;
+}
+function _toolShortName(name){
+ const raw=String(name||'').trim();
+ if(!raw) return 'tool';
+ if(raw.startsWith('mcp__')){
+ const parts=raw.split('__').filter(Boolean);
+ if(parts.length>1) return parts.slice(1).join('/');
+ }
+ if(raw.startsWith('mcp.')){
+ const parts=raw.split('.').filter(Boolean);
+ if(parts.length>1) return parts.slice(1).join('/');
+ }
+ return raw;
+}
+function _transparentEventPreview(text){
+ const clean=_sanitizeThinkingDisplayText(String(text||'')).replace(/\s+/g,' ').trim();
+ if(!clean) return '';
+ return clean.length>180?`${clean.slice(0,177)}...`:clean;
+}
+function _transparentToolStatus(tc){
+ if(tc&&tc.is_error) return 'Failed';
+ if(tc&&tc.done===false) return 'Running';
+ return 'Completed';
+}
+function _copyEventToClipboard(row){
+ if(!row) return;
+ const type=row.getAttribute('data-event-type');
+ let text='';
+ let label='event';
+ if(type==='tool'){
+ const tc=row._tcData||{};
+ label=`tool ${tc.name||row.getAttribute('data-event-name')||'tool'}`;
+ const parts=[`tool: ${tc.name||''}`];
+ if(tc.args&&Object.keys(tc.args).length) parts.push('args: '+JSON.stringify(tc.args,null,2));
+ if(tc.snippet) parts.push('output:\n'+String(tc.snippet));
+ text=parts.join('\n');
+ }else if(type==='thinking'){
+ const pre=row.querySelector('.thinking-card-body pre');
+ text=pre?pre.textContent:(row.textContent||'').replace(/^\s*Thinking\s*/i,'');
+ label='thinking';
+ }else{
+ text=row.textContent||'';
+ }
+ const fallback=()=>{
+ try{
+ const ta=document.createElement('textarea');
+ ta.value=text;
+ ta.setAttribute('readonly','');
+ ta.style.position='absolute';
+ ta.style.left='-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok=document.execCommand('copy');
+ document.body.removeChild(ta);
+ if(typeof showToast==='function') showToast(ok?(t('copied')||'Copied'):(t('copy_failed')||'Copy failed'),1600);
+ }catch(_){
+ if(typeof showToast==='function') showToast(t('copy_failed')||'Copy failed',2000,'error');
+ }
+ };
+ if(navigator&&navigator.clipboard&&navigator.clipboard.writeText){
+ navigator.clipboard.writeText(text).then(()=>{
+ if(typeof showToast==='function') showToast(`${t('copied')||'Copied'} ${label}`,1600);
+ }).catch(fallback);
+ }else{
+ fallback();
+ }
+}
+function _attachCopyButton(header){
+ if(!header) return null;
+ // Reuse ANY existing copy button (handles both .transparent-event-copy
+ // added by this function AND the legacy .thinking-copy-btn baked into the
+ // thinking-card template). Returning the existing one prevents the
+ // duplicate copy buttons that appeared in thinking boxes.
+ const existing=header.querySelector('.transparent-event-copy,.thinking-copy-btn');
+ if(existing){
+ // Normalise the class so CSS treats them identically.
+ if(!existing.classList.contains('transparent-event-copy')){
+ existing.classList.add('transparent-event-copy');
+ }
+ return existing;
+ }
+ btn=document.createElement('span');
+ btn.className='transparent-event-copy';
+ btn.setAttribute('role','button');
+ btn.setAttribute('tabindex','0');
+ btn.setAttribute('aria-label',t('copy')||'Copy');
+ btn.setAttribute('data-transparent-copy','1');
+ btn.title=t('copy')||'Copy';
+ btn.innerHTML=` `;
+ const handler=function(ev){
+ ev.stopPropagation();
+ ev.preventDefault();
+ _copyEventToClipboard(header.closest('.transparent-event-row'));
+ };
+ btn.onclick=handler;
+ btn.onkeydown=function(ev){
+ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();handler(ev);}
+ };
+ // Place the copy button at a FIXED flexbox position regardless of
+ // whether a toggle or status badge is present: always right before
+ // the toggle. The CSS uses flexbox order to keep it visually stable
+ // even if other elements are inserted between header and toggle.
+ const toggle=header.querySelector('.tool-card-toggle,.thinking-card-toggle');
+ if(toggle&&toggle.parentNode===header) header.insertBefore(btn,toggle);
+ else header.appendChild(btn);
+ return btn;
+}
+function _transparentEventCountLabel(toolCount){
+ return toolCount?`Trace: ${toolCount} ${toolCount===1?'tool':'tools'}`:'Trace';
+}
+function _setTransparentDetailMode(tab, mode){
+ const row=tab&&tab.closest?tab.closest('.transparent-event-row'):null;
+ const detail=row&&row.querySelector('.tool-card-detail');
+ if(!detail) return;
+ const next=mode==='output'?'output':'full';
+ detail.setAttribute('data-transparent-detail-mode',next);
+ detail.querySelectorAll('.transparent-detail-mode').forEach(el=>{
+ el.classList.toggle('active', el===tab || el.getAttribute('data-mode')===next);
+ });
+}
+function _setTransparentCardOpen(card, open){
+ if(!card) return;
+ const expanded=!!open;
+ card.classList.toggle('open',expanded);
+ const row=card.closest&&card.closest('.transparent-event-row');
+ if(row) row.setAttribute('data-expanded',expanded?'1':'0');
+ const header=card.querySelector('.tool-card-header,.thinking-card-header');
+ if(header) header.setAttribute('aria-expanded',expanded?'true':'false');
+}
+function _wireTransparentHeaderToggle(header){
+ if(!header||header.getAttribute('data-transparent-toggle-bound')==='1') return;
+ header.setAttribute('data-transparent-toggle-bound','1');
+ header.onclick=function(ev){
+ const target=ev&&ev.target;
+ if(target&&target.closest&&target.closest('.transparent-event-copy,.transparent-detail-mode,.tool-card-more')) return;
+ const card=this.closest('.tool-card,.thinking-card');
+ _setTransparentCardOpen(card,!(card&&card.classList.contains('open')));
+ };
+ header.onkeydown=function(ev){
+ if(ev.key!=='Enter'&&ev.key!==' ') return;
+ ev.preventDefault();
+ const card=this.closest('.tool-card,.thinking-card');
+ _setTransparentCardOpen(card,!(card&&card.classList.contains('open')));
+ };
+ header.setAttribute('role','button');
+ header.setAttribute('tabindex','0');
+}
+function _transparentToolDetailHtml(tc, status){
+ const args=tc&&tc.args&&typeof tc.args==='object'?tc.args:{};
+ const argEntries=Object.entries(args);
+ const meta=[
+ ['tool', tc&&tc.name?tc.name:'tool'],
+ ['status', status||_transparentToolStatus(tc)],
+ ];
+ if(tc&&tc.duration!==undefined&&tc.duration!==null) meta.push(['duration', String(tc.duration)]);
+ const preview=String((tc&&(tc.snippet||tc.preview||tc.result||tc.output))||'').trim();
+ const argHtml=[...meta,...argEntries].map(([k,v])=>`${esc(String(k))} ${esc(typeof v==='string'?v:JSON.stringify(v,null,2))}
`).join('');
+ return ``;
+}
+function _syncTransparentEventControls(turn){
+ if(!turn||!isTransparentStream()) return;
+ const blocks=_assistantTurnBlocks(turn);
+ if(!blocks) return;
+ const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row,[data-transparent-event-row="1"]'));
+ const toolCount=rows.filter(row=>row.getAttribute('data-event-type')==='tool').length;
+ let bar=blocks.querySelector(':scope > .transparent-event-controls');
+ if(!rows.length){
+ if(bar) bar.remove();
+ return;
+ }
+ if(!bar){
+ bar=document.createElement('div');
+ bar.className='transparent-event-controls';
+ const label=document.createElement('span');
+ label.className='transparent-event-controls-label';
+ label.setAttribute('data-transparent-tool-count','1');
+ const expand=document.createElement('span');
+ expand.className='transparent-event-control';
+ expand.setAttribute('role','button');
+ expand.setAttribute('tabindex','0');
+ expand.setAttribute('data-transparent-expand-all','1');
+ expand.textContent=t('expand_all')||'Expand all';
+ expand.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);};
+ expand.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),true);}};
+ const collapse=document.createElement('span');
+ collapse.className='transparent-event-control';
+ collapse.setAttribute('role','button');
+ collapse.setAttribute('tabindex','0');
+ collapse.setAttribute('data-transparent-collapse-all','1');
+ collapse.textContent=t('collapse_all')||'Collapse all';
+ collapse.onclick=function(ev){ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);};
+ collapse.onkeydown=function(ev){if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();ev.stopPropagation();_setTransparentRowsExpanded(this.closest('.assistant-turn'),false);}};
+ bar.appendChild(label);
+ bar.appendChild(expand);
+ bar.appendChild(collapse);
+ // Guard: firstChild may be null (empty blocks) or orphaned from a prior
+ // DOM rebuild. Only insertBefore when it is still a child of blocks.
+ if(blocks.firstChild&&blocks.firstChild.parentNode===blocks) blocks.insertBefore(bar, blocks.firstChild);
+ else blocks.appendChild(bar);
+ }
+ const label=bar.querySelector('.transparent-event-controls-label');
+ if(label){
+ label.textContent=_transparentEventCountLabel(toolCount);
+ label.setAttribute('data-transparent-tool-count',String(toolCount));
+ }
+ bar.setAttribute('data-tool-count',String(toolCount));
+ // Wire the Hermes chat name tag toggle for the live turn.
+ _wireTransparentTurnToggle(turn);
+}
+function _decorateTransparentEventRow(row, opts){
+ if(!row) return row;
+ opts=opts||{};
+ const type=String(opts.type||row.getAttribute('data-event-type')||'event');
+ row.classList.add('transparent-event-row');
+ row.setAttribute('data-transparent-event-row','1');
+ row.setAttribute('data-transparent-stream','1');
+ row.setAttribute('data-event-type',type);
+ if(opts.name) row.setAttribute('data-event-name',String(opts.name));
+ if(opts.segmentSeq) row.setAttribute('data-live-segment-seq',String(opts.segmentSeq));
+ if(opts.burstId) row.setAttribute('data-activity-burst-id',String(opts.burstId));
+ if(type==='tool'){
+ const tc=opts.toolCall||row._tcData||{};
+ const name=String(opts.name||tc.name||'tool');
+ row.setAttribute('data-tool-name',name);
+ const status=String(opts.status||_transparentToolStatus(tc));
+ row.setAttribute('data-event-status',status);
+ const header=row.querySelector('.tool-card-header');
+ const card=row.querySelector('.tool-card');
+ if(card) card.classList.add('transparent-event-card');
+ if(header){
+ const nameEl=header.querySelector('.tool-card-name');
+ if(nameEl) nameEl.textContent=(status==='Running'?`Running: ${_toolShortName(name)}`:_toolShortName(name));
+ let statusEl=header.querySelector('.transparent-event-status');
+ if(!statusEl){
+ statusEl=document.createElement('span');
+ statusEl.className='transparent-event-status';
+ const toggle=header.querySelector('.tool-card-toggle');
+ // Guard against stale toggle (see thinking-preview fix above).
+ if(toggle&&toggle.parentNode===header) header.insertBefore(statusEl,toggle);
+ else header.appendChild(statusEl);
+ }
+ if(status==='Completed'){
+ statusEl.textContent='';
+ statusEl.removeAttribute('data-status');
+ }else{
+ statusEl.textContent=status;
+ statusEl.setAttribute('data-status',status.toLowerCase());
+ }
+ row.setAttribute('data-event-status',status);
+ // Update the 3D progress bar to reflect the new status.
+ const progress=card.querySelector('.transparent-event-progress');
+ if(progress){
+ if(status==='Completed'||status==='Failed'){
+ progress.removeAttribute('data-progress-running');
+ progress.setAttribute('data-progress-percent','100%');
+ progress.style.setProperty('--transparent-progress-percent','100%');
+ }else if(status==='Running'){
+ progress.setAttribute('data-progress-running','1');
+ progress.setAttribute('data-progress-percent','60%');
+ progress.style.setProperty('--transparent-progress-percent','60%');
+ }
+ }
+ let detail=row.querySelector('.tool-card-detail');
+ if(!detail&&card){
+ card.insertAdjacentHTML('beforeend',_transparentToolDetailHtml(tc,status));
+ detail=row.querySelector('.tool-card-detail');
+ if(!header.querySelector('.tool-card-toggle')){
+ const toggle=document.createElement('span');
+ toggle.className='tool-card-toggle';
+ toggle.innerHTML=li('chevron-right',12);
+ header.appendChild(toggle);
+ }
+ }
+ if(detail&&!detail.querySelector('.transparent-detail-modes')){
+ const modes=document.createElement('div');
+ modes.className='transparent-detail-modes';
+ modes.setAttribute('role','tablist');
+ modes.innerHTML=`Full Output `;
+ // Guard: firstChild may be orphaned from a prior DOM rebuild.
+ const firstChild=detail.firstChild;
+ if(firstChild&&firstChild.parentNode===detail) detail.insertBefore(modes, firstChild);
+ else detail.appendChild(modes);
+ detail.setAttribute('data-transparent-detail-mode','full');
+ }
+ _wireTransparentHeaderToggle(header);
+ _attachCopyButton(header);
+ }
+ // Attach the 3D progress bar BEFORE the early return so tool rows
+ // also get the bottom bar (the function used to return before
+ // reaching the bar attachment, which left tool rows bar-less).
+ _attachProgressBar(row, opts);
+ return row;
+ }
+ if(type==='thinking'){
+ row.classList.add('transparent-thinking-event');
+ row.setAttribute('data-event-name','thinking');
+ const card=row.querySelector('.thinking-card');
+ const header=row.querySelector('.thinking-card-header');
+ if(card) card.classList.add('transparent-event-card');
+ if(header){
+ const label=header.querySelector('.thinking-card-label');
+ if(label) label.textContent='Thinking';
+ let preview=header.querySelector('.transparent-event-preview');
+ const previewText=_transparentEventPreview(opts.preview||opts.text||row.textContent||'');
+ if(previewText){
+ if(!preview){
+ preview=document.createElement('span');
+ preview.className='transparent-event-preview';
+ const toggle=header.querySelector('.thinking-card-toggle');
+ // Guard: toggle may be orphaned from a prior DOM rebuild
+ // (renderMessages reuses row objects that have stale children).
+ // Only insertBefore when toggle is still a child of header;
+ // otherwise fall back to appendChild.
+ if(toggle&&toggle.parentNode===header) header.insertBefore(preview,toggle);
+ else header.appendChild(preview);
+ }
+ preview.textContent=previewText;
+ }else if(preview){
+ preview.remove();
+ }
+ _wireTransparentHeaderToggle(header);
+ _attachCopyButton(header);
+ }
+ _attachProgressBar(row, opts);
+ }
+ return row;
+}
+// ── 3D progress bar (loading path / follow-up indicator) ───────────
+// Each transparent event card has a thin 3D bar at its bottom edge.
+// While the underlying tool is running (status === 'Running') the bar
+// shows a shimmer animation; once the tool completes the bar fills to
+// 100% and stops. The bar doubles as a visual step-break between rows
+// in the stack, so the eye reads the stream as discrete steps.
+function _attachProgressBar(row, opts){
+ if(!row) return;
+ opts=opts||{};
+ const card=row.querySelector('.tool-card,.thinking-card');
+ if(!card) return;
+ let bar=card.querySelector('.transparent-event-progress');
+ if(!bar){
+ bar=document.createElement('div');
+ bar.className='transparent-event-progress';
+ // Append to the card so it sits flush at the bottom edge.
+ card.appendChild(bar);
+ }
+ // Set the running state from opts or current status.
+ const status=String(opts.status||(row.getAttribute('data-event-status')||''));
+ const isRunning=(status==='Running'||status==='running');
+ const isCompleted=(status==='Completed'||status==='completed'||status==='Failed'||status==='failed');
+ if(isRunning) bar.setAttribute('data-progress-running','1');
+ else bar.removeAttribute('data-progress-running');
+ if(isCompleted){
+ bar.setAttribute('data-progress-percent','100%');
+ bar.style.setProperty('--transparent-progress-percent','100%');
+ }else if(isRunning){
+ bar.setAttribute('data-progress-percent','60%');
+ bar.style.setProperty('--transparent-progress-percent','60%');
+ }else{
+ bar.removeAttribute('data-progress-percent');
+ bar.style.removeProperty('--transparent-progress-percent');
+ }
+}
+function _setTransparentRowsExpanded(root, expanded){
+ const scope=root||document;
+ scope.querySelectorAll('.transparent-event-row .tool-card,.transparent-event-row .thinking-card').forEach(card=>{
+ _setTransparentCardOpen(card,!!expanded);
+ });
+}
+// ── Transparent turn-level collapse (Hermes chat name tag) ───────────────
+// In transparent_stream mode the assistant role label is the turn's "name
+// tag". Clicking it collapses the entire event stack underneath so the
+// transcript shows only the final answer (Output only). A chevron on the
+// role telegraph the affordance; the blocks body animates with the same
+// max-height transition used by individual event cards. Persisted via
+// data-attribute only — the turn's render path reads it on rebuild.
+const _transparentTurnCollapsedStates={}; // key: `${sid}:${turnMsgIdx}` → boolean
+function _wireTransparentTurnToggle(turn){
+ if(!turn||turn.getAttribute('data-transparent-turn-toggle-bound')==='1') return;
+ if(!isTransparentStream()) return;
+ const role=turn.querySelector('.msg-role.assistant');
+ if(!role) return;
+ turn.setAttribute('data-transparent-turn-toggle-bound','1');
+ // Add chevron if missing.
+ if(!role.querySelector('.transparent-turn-chevron')){
+ const chev=document.createElement('span');
+ chev.className='transparent-turn-chevron';
+ chev.innerHTML=li('chevron-down',10);
+ role.appendChild(chev);
+ }
+ role.setAttribute('role','button');
+ role.setAttribute('tabindex','0');
+ role.setAttribute('aria-expanded',turn.getAttribute('data-transparent-turn-collapsed')==='1'?'false':'true');
+ const toggle=function(ev){
+ if(ev&&ev.target&&ev.target.closest&&ev.target.closest('.msg-tps-inline')) return;
+ const collapsed=turn.getAttribute('data-transparent-turn-collapsed')==='1';
+ turn.setAttribute('data-transparent-turn-collapsed',collapsed?'0':'1');
+ role.setAttribute('aria-expanded',collapsed?'true':'false');
+ // Persist state across DOM rebuilds.
+ if(S.session){
+ const seg=turn.querySelector('.assistant-segment');
+ if(seg){
+ const mi=seg.getAttribute('data-msg-idx');
+ if(mi!=null) _transparentTurnCollapsedStates[`${S.session.session_id}:${mi}`]=!collapsed;
+ }
+ }
+ };
+ role.onclick=toggle;
+ role.onkeydown=function(ev){
+ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();toggle(ev);}
+ };
+}
+// ── Transparent old-event fading (medium → low) ───────────────────────────
+// In long streams the earliest rows fade to a lower opacity so the user's
+// eye lands on the most recent activity. The fade is per-turn: the newest
+// event stays at full opacity, each earlier event drops one step. Floors
+// at 0.32 so labels stay readable.
+function _applyTransparentRowFading(turn){
+ if(!turn||!isTransparentStream()) return;
+ const blocks=_assistantTurnBlocks(turn);
+ if(!blocks) return;
+ const rows=Array.from(blocks.querySelectorAll(':scope > .transparent-event-row'));
+ const total=rows.length;
+ for(let i=0;i${esc(durationText)}`);
+ if(ttftText) parts.push(`TTFT ${esc(ttftText)} `);
+ if(tokensText) parts.push(`${esc(tokensText)} `);
+ if(statusText) parts.push(`${esc(statusText)} `);
+ if(!parts.length) return '';
+ return ``;
+}
+function _renderTransparentTurnFooter(turn, opts){
+ if(!turn||!isTransparentStream()) return;
+ const blocks=_assistantTurnBlocks(turn);
+ if(!blocks) return;
+ const hasRows=blocks.querySelector(':scope > .transparent-event-row');
+ if(!hasRows){
+ // No events → no footer (the answer itself carries the duration).
+ const existing=turn.querySelector('.transparent-turn-footer');
+ if(existing) existing.remove();
+ return;
+ }
+ const durationText=opts&&opts.durationText||'';
+ const ttftText=opts&&opts.ttftText||'';
+ const tokensText=opts&&opts.tokensText||'';
+ const statusText=opts&&opts.statusText||(t('done')||'Done');
+ const html=_transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText);
+ let footer=turn.querySelector('.transparent-turn-footer');
+ if(!html){
+ if(footer) footer.remove();
+ return;
+ }
+ if(!footer){
+ footer=document.createElement('div');
+ footer.className='transparent-turn-footer';
+ const blocks=turn.querySelector('.assistant-turn-blocks');
+ // Guard: nextSibling may be null (blocks is last child) or orphaned from
+ // a prior DOM rebuild. Only insertBefore when it is still a child of turn.
+ if(blocks&&blocks.nextSibling&&blocks.nextSibling.parentNode===turn){
+ turn.insertBefore(footer, blocks.nextSibling);
+ }else{
+ turn.appendChild(footer);
+ }
+ }
+ footer.innerHTML=html.replace(/^
${hasDetail?`
${tc.args&&Object.keys(tc.args).length?`
`:''}
${displaySnippet?`
${tc.is_diff||_snippetLooksLikeDiff(displaySnippet)?`${_colorDiffLines(displaySnippet)}`:esc(displaySnippet)}
@@ -9664,6 +10265,55 @@ function appendLiveToolCard(tc){
const burstAnchor=burstId?_findLatestVisibleLiveAssistantByBurst(inner, burstId):null;
const anchor=segmentAnchor||burstAnchor||_findLatestVisibleLiveAssistant(inner)||children.filter(el=>el.matches('[data-live-assistant="1"]')).pop();
const effectiveSegmentSeq=anchor&&anchor.getAttribute?anchor.getAttribute('data-live-segment-seq')||segmentSeq:segmentSeq;
+ if(isTransparentStream()){
+ const insertTransparentRow=(row)=>{
+ const liveFooter=inner.querySelector('#liveRunStatus');
+ const previousRows=Array.from(inner.querySelectorAll('.transparent-event-row'));
+ const previous=previousRows[previousRows.length-1]||null;
+ if(previous&&previous.parentElement===inner){
+ previous.insertAdjacentElement('afterend',row);
+ }else if(anchor&&anchor.parentElement===inner){
+ anchor.insertAdjacentElement('afterend',row);
+ }else if(liveFooter&&liveFooter.parentElement===inner){
+ inner.insertBefore(row,liveFooter);
+ }else{
+ inner.appendChild(row);
+ }
+ };
+ if(tid){
+ const existing=inner.querySelector(`.transparent-event-row[data-live-tid="${CSS.escape(tid)}"],.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
+ if(existing){
+ const replacement=_decorateTransparentEventRow(buildToolCard(tc),{
+ type:'tool',
+ name:tc&&tc.name,
+ status:_transparentToolStatus(tc),
+ toolCall:tc,
+ segmentSeq:effectiveSegmentSeq,
+ burstId,
+ });
+ replacement.dataset.liveTid=tid;
+ existing.replaceWith(replacement);
+ _syncTransparentEventControls(turn);
+ _moveLiveRunStatusToTurnEnd();
+ if(typeof scrollIfPinned==='function') scrollIfPinned();
+ return;
+ }
+ }
+ const row=_decorateTransparentEventRow(buildToolCard(tc),{
+ type:'tool',
+ name:tc&&tc.name,
+ status:_transparentToolStatus(tc),
+ toolCall:tc,
+ segmentSeq:effectiveSegmentSeq,
+ burstId,
+ });
+ if(tid) row.dataset.liveTid=tid;
+ insertTransparentRow(row);
+ _syncTransparentEventControls(turn);
+ _moveLiveRunStatusToTurnEnd();
+ if(typeof scrollIfPinned==='function') scrollIfPinned();
+ return;
+ }
if(anchor) _removeEmptyLiveWorklogShells(inner);
const group=ensureLiveWorklogContainer(inner,{
anchor,
@@ -9743,7 +10393,7 @@ function _findLiveAssistantAnchorForSegment(inner, segmentSeq){
function clearLiveToolCards(){
if(typeof _clearActivityElapsedTimer==='function') _clearActivityElapsedTimer();
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
- if(inner) inner.querySelectorAll('.live-worklog[data-live-worklog-shell],.tool-worklog-group[data-live-tool-call-group],.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove());
+ if(inner) inner.querySelectorAll('.live-worklog[data-live-worklog-shell],.tool-worklog-group[data-live-tool-call-group],.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]:not(.transparent-event-row)').forEach(el=>el.remove());
// Reset the per-turn user expand intent so the next turn starts at the
// default collapsed state (#1298).
if(typeof _clearLiveActivityUserIntent==='function') _clearLiveActivityUserIntent();
@@ -9774,6 +10424,11 @@ function ensureLiveWorklogShell(){
}
const blocks=_assistantTurnBlocks(turn);
if(!blocks) return null;
+ if(isTransparentStream()){
+ _moveLiveRunStatusToTurnEnd();
+ scrollIfPinned();
+ return blocks;
+ }
const group=ensureLiveWorklogContainer(blocks,{
activityKey:_activityKeyForLiveTurn(),
});
@@ -10515,6 +11170,15 @@ function finalizeThinkingCard(){
// stream that started it, not the session currently displayed.
const _guardTurn = $('liveAssistantTurn');
if(_guardTurn && S.session && _guardTurn.dataset.sessionId !== S.session.session_id) return;
+ if(isTransparentStream()){
+ const row=$('thinkingRow');
+ if(row){
+ row.removeAttribute('id');
+ row.removeAttribute('data-thinking-active');
+ row.removeAttribute('data-live-thinking');
+ }
+ return;
+ }
if(!isSimplifiedToolCalling()){
const row=$('thinkingRow');
if(!row) return;
@@ -10591,6 +11255,42 @@ function appendThinking(text='', options){
burstId?`burst:${burstId}`:
'turn'
));
+ if(isTransparentStream()){
+ let row=blocks.querySelector(`.agent-activity-thinking[data-live-thinking="1"][data-live-thinking-key="${CSS.escape(thinkingKey)}"]`);
+ if(!row){
+ row=_thinkingActivityNode(clean, false);
+ row.id='thinkingRow';
+ row.setAttribute('data-live-thinking','1');
+ row.setAttribute('data-live-thinking-key',thinkingKey);
+ if(segmentSeq) row.setAttribute('data-live-segment-seq',segmentSeq);
+ if(burstId) row.setAttribute('data-activity-burst-id',burstId);
+ blocks.querySelectorAll('.agent-activity-thinking[data-thinking-active="1"]').forEach(el=>{
+ if(el!==row){
+ el.removeAttribute('id');
+ el.removeAttribute('data-thinking-active');
+ el.removeAttribute('data-live-thinking');
+ }
+ });
+ row.setAttribute('data-thinking-active','1');
+ const liveFooter=blocks.querySelector('#liveRunStatus');
+ if(liveFooter&&liveFooter.parentElement===blocks) blocks.insertBefore(row,liveFooter);
+ else blocks.appendChild(row);
+ }else{
+ _renderThinkingInto(row, clean);
+ }
+ row.id='thinkingRow';
+ row.setAttribute('data-thinking-active','1');
+ _decorateTransparentEventRow(row,{
+ type:'thinking',
+ text:clean,
+ preview:clean,
+ segmentSeq,
+ burstId,
+ });
+ _syncTransparentEventControls(turn);
+ if(typeof scrollIfPinned==='function') scrollIfPinned();
+ return;
+ }
const group=ensureLiveWorklogContainer(blocks,{
activityKey:options.activityKey||(S.activeStreamId?'live:'+S.activeStreamId:null),
});
@@ -10622,12 +11322,23 @@ function appendThinking(text='', options){
}
function updateThinking(text='', options){appendThinking(text, options);}
function removeThinking(){
+ if(isTransparentStream()){
+ const liveTurn=$('liveAssistantTurn');
+ const blocks=_assistantTurnBlocks(liveTurn);
+ if(blocks) blocks.querySelectorAll('.agent-activity-thinking[data-thinking-active="1"]').forEach(row=>{
+ row.removeAttribute('id');
+ row.removeAttribute('data-thinking-active');
+ row.removeAttribute('data-live-thinking');
+ });
+ if(liveTurn&&blocks&&!blocks.children.length) liveTurn.remove();
+ return;
+ }
if(!isSimplifiedToolCalling()){
const el=$('thinkingRow');
if(el) el.remove();
- const turn=$('liveAssistantTurn');
- const blocks=_assistantTurnBlocks(turn);
- if(turn&&blocks&&!blocks.children.length) turn.remove();
+ const liveTurn=$('liveAssistantTurn');
+ const blocks=_assistantTurnBlocks(liveTurn);
+ if(liveTurn&&blocks&&!blocks.children.length) liveTurn.remove();
return;
}
const turn=$('liveAssistantTurn');
diff --git a/tests/test_issue3592_thinking_settlement.py b/tests/test_issue3592_thinking_settlement.py
index 628c7c9f0..54ebfbb0d 100644
--- a/tests/test_issue3592_thinking_settlement.py
+++ b/tests/test_issue3592_thinking_settlement.py
@@ -65,7 +65,7 @@ def test_thinking_only_turns_use_worklog_duration():
"""Thinking-only turns now create a folded Worklog group, so that group owns
the "Done in ..." duration instead of the final answer footer.
"""
- m = re.search(r"const compactWorklogForMessage=isSimplifiedToolCalling\(\)&&([^;]+);", UI_JS)
+ m = re.search(r"const compactWorklogForMessage=isCompactWorklogMode\(\)&&([^;]+);", UI_JS)
assert m, "compactWorklogForMessage suppression condition not found"
cond = m.group(1)
assert "toolCallAssistantIdxs.has(mi)" in cond
diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py
new file mode 100644
index 000000000..a9ac5629d
--- /dev/null
+++ b/tests/test_issue3820_chat_activity_display_mode.py
@@ -0,0 +1,435 @@
+"""Regression tests for issue #3820 chat activity display mode."""
+
+import json
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
+BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
+PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
+INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
+I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
+STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
+
+
+def _transparentEventCountLabelBlock(ui_js):
+ """Return the body of `_transparentEventCountLabel` as a string slice."""
+ start = ui_js.index("function _transparentEventCountLabel")
+ end = ui_js.index("\nfunction ", start + 1)
+ return ui_js[start:end]
+
+
+def test_chat_activity_display_mode_defaults_to_compact_worklog(monkeypatch, tmp_path):
+ import api.config as config
+
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
+
+ loaded = config.load_settings()
+
+ assert loaded["chat_activity_display_mode"] == "compact_worklog"
+
+
+def test_chat_activity_display_mode_persists_transparent_stream_and_rejects_invalid(monkeypatch, tmp_path):
+ import api.config as config
+
+ settings_path = tmp_path / "settings.json"
+ monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
+
+ saved = config.save_settings({"chat_activity_display_mode": "transparent_stream"})
+ assert saved["chat_activity_display_mode"] == "transparent_stream"
+ assert json.loads(settings_path.read_text(encoding="utf-8"))["chat_activity_display_mode"] == "transparent_stream"
+
+ saved = config.save_settings({"chat_activity_display_mode": "invalid_mode"})
+ assert saved["chat_activity_display_mode"] == "transparent_stream"
+ assert json.loads(settings_path.read_text(encoding="utf-8"))["chat_activity_display_mode"] == "transparent_stream"
+
+
+def test_transparent_stream_uses_dedicated_mode_not_simplified_tool_calling():
+ assert "function chatActivityMode()" in UI_JS
+ assert "function isTransparentStream()" in UI_JS
+ assert "function isCompactWorklogMode()" in UI_JS
+ assert "chatActivityMode()==='transparent_stream'" in UI_JS
+ assert "window._chatActivityDisplayMode" in BOOT_JS
+ assert "window._chatActivityDisplayMode" in PANELS_JS
+ assert "window._simplifiedToolCalling=true" in BOOT_JS
+ assert "window._simplifiedToolCalling=true" in PANELS_JS
+
+
+def test_transparent_stream_live_branch_uses_direct_rows():
+ decorator_start = UI_JS.index("function _decorateTransparentEventRow(row, opts){")
+ decorator_end = UI_JS.index("function _setTransparentRowsExpanded", decorator_start)
+ decorator_block = UI_JS[decorator_start:decorator_end]
+ assert "data-transparent-event-row" in decorator_block
+ assert "transparent-event-status" in decorator_block
+ # Title carries the simple tool name (short form for MCP); status text
+ # is only shown for non-default states.
+ assert "_toolShortName(name)" in decorator_block
+ assert "if(status==='Completed'){" in decorator_block
+ assert "function _syncTransparentEventControls" in UI_JS
+ assert "function _transparentEventCountLabel" in UI_JS
+ assert "data-transparent-tool-count" in UI_JS
+ assert "data-tool-count" in UI_JS
+ # Transparent mode labels the event controls as a trace, not a Worklog
+ # Activity summary.
+ assert "return toolCount?`Trace: ${toolCount} ${toolCount===1?'tool':'tools'}`:'Trace';" in UI_JS
+ assert "return `Activity: ${toolCount} ${toolCount===1?'tool':'tools'}`" not in UI_JS
+ assert "Activity: ${parts.join(' · ')}" not in UI_JS
+ # The summary must not list thinking count (DESIGN.md:75 forbids a second
+ # trailing count badge, and thinking rows render as their own rows).
+ assert "thinkingCount" not in _transparentEventCountLabelBlock(UI_JS)
+ assert "function _setTransparentRowsExpanded" in UI_JS
+ assert "function _setTransparentCardOpen" in UI_JS
+ assert "function _wireTransparentHeaderToggle" in UI_JS
+ assert "function _transparentToolDetailHtml" in UI_JS
+ assert "function _setTransparentDetailMode" in UI_JS
+ assert "transparent-detail-modes" in UI_JS
+ # Spec R4: key on own line, value on next line, no inline pair.
+ assert 'class="tool-arg-pair"' in UI_JS
+ assert 'class="tool-arg-key"' in UI_JS
+ assert 'class="tool-arg-val"' in UI_JS
+ # Spec R5: controls are span[role=button], not
(text-link style)
+ assert 'setAttribute(\'role\',\'button\')' in UI_JS
+ # Spec R4: detail-mode tabs are span[role=tab], not
+ assert 'role="tab"' in UI_JS
+ assert "transparent-detail-modes" in UI_JS
+
+ append_thinking_start = UI_JS.index("function appendThinking(text='', options){")
+ append_thinking_end = UI_JS.index("function updateThinking", append_thinking_start)
+ append_thinking_block = UI_JS[append_thinking_start:append_thinking_end]
+ assert "if(isTransparentStream())" in append_thinking_block
+ assert "turn=_createAssistantTurn()" in append_thinking_block
+ assert "row.id='thinkingRow'" in append_thinking_block
+ assert "_decorateTransparentEventRow(row,{" in append_thinking_block
+ assert "type:'thinking'" in append_thinking_block
+ assert "data-live-thinking-key" in append_thinking_block
+ assert "_syncTransparentEventControls(turn)" in append_thinking_block
+
+ append_tool_start = UI_JS.index("function appendLiveToolCard(tc){")
+ append_tool_end = UI_JS.index("function _findLatestLiveAssistantByBurst", append_tool_start)
+ append_tool_block = UI_JS[append_tool_start:append_tool_end]
+ assert "if(isTransparentStream())" in append_tool_block
+ assert "_decorateTransparentEventRow(buildToolCard(tc)" in append_tool_block
+ assert "_syncTransparentEventControls(turn)" in append_tool_block
+ assert "ensureLiveWorklogContainer(inner" in append_tool_block # compact_worklog fallback remains intact
+
+
+def test_settings_ui_exposes_chat_activity_display_mode_selector():
+ assert 'id="settingsChatActivityDisplayMode"' in INDEX_HTML
+ assert 'data-chat-activity-mode="compact_worklog"' in INDEX_HTML
+ assert 'data-chat-activity-mode="transparent_stream"' in INDEX_HTML
+ assert 'value="compact_worklog"' in INDEX_HTML
+ assert 'value="transparent_stream"' in INDEX_HTML
+ assert 'data-i18n="settings_label_chat_activity_display_mode"' in INDEX_HTML
+ assert "chat_activity_display_mode" in PANELS_JS
+ assert "settingsChatActivityDisplayMode" in PANELS_JS
+ assert "function _syncChatActivityDisplayModeControl" in PANELS_JS
+ assert "function _pickChatActivityDisplayMode" in PANELS_JS
+ assert "body.chat_activity_display_mode" in PANELS_JS
+ assert "renderMessages({preserveScroll:true})" in PANELS_JS
+ assert "settings_label_chat_activity_display_mode" in I18N_JS
+ assert "settings_desc_chat_activity_display_mode" in I18N_JS
+
+
+def test_transparent_event_row_pre_3820_visual_rhythm():
+ """Restore the pre-#3820 visual rhythm: collapsed rows are flat
+ list-items (no border) so the stack reads as a clean list, and tool
+ rows are distinguished from thinking rows by a warm low-opacity
+ secondary tint. When the user clicks a row, it expands into a small
+ card with a smooth height animation.
+
+ Body itself stays flush with the card's border (no inner chrome), but
+ contains a bordered code block for output. Status is rendered as a
+ small text badge only when the tool isn't Completed.
+ """
+ # Per-event card (when expanded): thin border, surface bg, radius, soft shadow.
+ assert "border:1px solid var(--border-subtle)" in STYLE_CSS
+ # Softened edges: 12px (was 10px, was 6px originally).
+ assert "border-radius:12px" in STYLE_CSS
+ assert "background:var(--surface)" in STYLE_CSS
+
+ # Header: single flex line, low height, hover subtle.
+ # Compact rhythm: 20px header (was 22px) for tighter box size.
+ assert "min-height:20px" in STYLE_CSS
+ assert "display:flex" in STYLE_CSS
+
+ # Thinking row uses the warm low-opacity secondary tint (per-theme) PLUS
+ # a thin transparent border so the box reads as a unit even when collapsed.
+ assert "background:linear-gradient(90deg,color-mix(in srgb,var(--accent-bg-strong)" in STYLE_CSS
+ # All transparent event boxes carry a very-low-opacity border so each box
+ # is visually anchored. The thinking row's border is slightly stronger.
+ assert "color-mix(in srgb,var(--accent) 58%,transparent)" in STYLE_CSS
+ assert "color-mix(in srgb,var(--accent) 38%,var(--border-subtle))" in STYLE_CSS
+
+ # Expanded body uses visible max-height / opacity animation for smooth height.
+ assert "max-height:0" in STYLE_CSS
+ assert "max-height:520px" in STYLE_CSS
+ assert "transition:max-height" in STYLE_CSS
+ assert "data-expanded" in UI_JS
+
+ # Output / JSON: bordered code block, low opacity, inside the row's body.
+ assert "background:var(--code-bg)" in STYLE_CSS
+ assert "border:1px solid var(--border-subtle)" in STYLE_CSS
+
+ # Tabs: text-link style, font-weight indicates active, no pill background.
+ assert ".transparent-detail-mode.active{color:var(--text);opacity:1;font-weight:600;}" in STYLE_CSS
+ assert ".transparent-detail-mode.active{background:var(--hover-bg)" not in STYLE_CSS
+
+ # Args layout: key on own line, value on next line in monospace.
+ assert ".tool-arg-pair{display:flex;flex-direction:column" in STYLE_CSS
+ # Compact size: key font 10px (was 10.5px, was 11px originally) — reduced
+ # by two notches for the smaller-box rhythm.
+ assert ".tool-arg-key{color:var(--muted);font-size:10px" in STYLE_CSS
+ assert "font-family:var(--font-mono);" in STYLE_CSS
+
+ # Tool rows must show a useful compact preview while collapsed.
+ assert ".transparent-event-row .tool-card-preview" in STYLE_CSS
+ assert ".transparent-event-row .tool-card:not(.open) .tool-card-preview" not in STYLE_CSS
+ assert "display:inline" in STYLE_CSS
+
+ # Per-row copy button still attached for both tool and thinking rows.
+ assert "function _attachCopyButton" in UI_JS
+ assert "function _copyEventToClipboard" in UI_JS
+ assert "navigator.clipboard.writeText" in UI_JS
+ decorator_block = UI_JS[UI_JS.index("function _decorateTransparentEventRow"):UI_JS.index("function _setTransparentRowsExpanded")]
+ assert decorator_block.count("_attachCopyButton(header)") >= 2
+ assert "_transparentToolDetailHtml(tc,status)" in decorator_block
+ assert "_wireTransparentHeaderToggle(header)" in decorator_block
+
+ # Bug fix: thinking blocks persist after the stream stops. removeThinking()
+ # and finalizeThinkingCard() must not delete the transparent thinking row;
+ # they only strip live attributes so the row can survive the settled render.
+ remove_start = UI_JS.index("function removeThinking()")
+ remove_end = UI_JS.index("\nfunction ", remove_start + 1)
+ remove_block = UI_JS[remove_start:remove_end]
+ transparent_remove_block = remove_block[remove_block.index("if(isTransparentStream())"):remove_block.index("const turn=$('liveAssistantTurn');")]
+ assert "row.removeAttribute('id')" in transparent_remove_block
+ assert "row.removeAttribute('data-thinking-active')" in transparent_remove_block
+ assert "row.removeAttribute('data-live-thinking')" in transparent_remove_block
+ assert "row.remove()" not in transparent_remove_block
+
+ finalize_start = UI_JS.index("function finalizeThinkingCard(){")
+ finalize_end = UI_JS.index("function appendThinking", finalize_start)
+ finalize_block = UI_JS[finalize_start:finalize_end]
+ transparent_finalize_block = finalize_block[finalize_block.index("if(isTransparentStream())"):finalize_block.index("if(!isSimplifiedToolCalling())")]
+ assert "row.removeAttribute('id')" in transparent_finalize_block
+ assert "row.removeAttribute('data-thinking-active')" in transparent_finalize_block
+ assert "row.removeAttribute('data-live-thinking')" in transparent_finalize_block
+ assert "row.remove()" not in transparent_finalize_block
+
+
+def test_transparent_stream_static_branch_bypasses_worklog_summary_and_adds_event_hooks():
+ marker = "// ── transparent_stream path: individual expandable event rows ──"
+ start = UI_JS.index(marker)
+ end = UI_JS.index("// Render per-turn duration", start)
+ transparent_branch = UI_JS[start:end]
+
+ assert "_decorateTransparentEventRow(_thinkingActivityNode(event.thinkingText,false)" in transparent_branch
+ assert "_decorateTransparentEventRow(buildToolCard(event.toolCall)" in transparent_branch
+ assert "type:'thinking'" in transparent_branch
+ assert "type:'tool'" in transparent_branch
+ assert "_syncTransparentEventControls(turn)" in transparent_branch
+ assert "_syncToolCallGroupSummary" not in transparent_branch
+ assert "ensureActivityGroup" not in transparent_branch
+ assert "_toolWorklogSummary" not in transparent_branch
+
+ render_message_start = UI_JS.index("const messageBelongsInWorklog=")
+ render_message_end = UI_JS.index("if(messageBelongsInWorklog)", render_message_start)
+ assert "isCompactWorklogMode()" in UI_JS[render_message_start:render_message_end]
+ assert "isSimplifiedToolCalling()" not in UI_JS[render_message_start:render_message_end]
+
+ thinking_store_start = UI_JS.index("if(thinkingText&&window._showThinking!==false)")
+ thinking_store_end = UI_JS.index("const hasVisibleBody=", thinking_store_start)
+ thinking_store_block = UI_JS[thinking_store_start:thinking_store_end]
+ assert "isTransparentStream()" in thinking_store_block
+ assert "assistantThinking.set(rawIdx, thinkingText)" in thinking_store_block
+
+
+def test_fade_text_effect_uses_dynamic_window_check():
+ """The fade text effect must read window._fadeTextEffect dynamically
+ on every call, and the Settings checkbox must update the live window flag
+ immediately so the current session can start fading without a reload."""
+ MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
+ # Locate the helper and confirm it reads the live value.
+ helper_start = MESSAGES_JS.index("function _shouldUseStreamFade(")
+ helper_end = MESSAGES_JS.index("\n function ", helper_start + 1)
+ helper_block = MESSAGES_JS[helper_start:helper_end]
+ assert "window._fadeTextEffect" in helper_block
+ # Should not return the captured const any more.
+ assert "return _streamFadeEnabledForStream;" not in helper_block
+
+ # The preferences listener must update the runtime flag immediately, not
+ # only after autosave/save completes.
+ fade_cb_start = PANELS_JS.index("const fadeTextCb=$('settingsFadeTextEffect');", 300000)
+ fade_cb_end = PANELS_JS.index("const terminalAutoExpandCb", fade_cb_start)
+ fade_cb_block = PANELS_JS[fade_cb_start:fade_cb_end]
+ assert "window._fadeTextEffect=fadeTextCb.checked" in fade_cb_block
+ assert "fadeTextCb.addEventListener('change',()=>{" in fade_cb_block
+ assert "_schedulePreferencesAutosave();" in fade_cb_block
+ # The change handler must not be the old direct autosave callback.
+ assert "addEventListener('change',_schedulePreferencesAutosave" not in fade_cb_block
+
+
+def test_thinking_blocks_persist_after_renderMessages():
+ """renderMessages() rebuilds the settled DOM and used to wipe every
+ .agent-activity-thinking row that lacked data-live-thinking="1" —
+ including the promoted permanent event rows that removeThinking()
+ now leaves behind. Guard the selector so promoted thinking rows
+ survive the rebuild."""
+ inner_sweep = ".agent-activity-thinking:not([data-live-thinking=\"1\"]):not([data-event-type=\"thinking\"])"
+ assert inner_sweep in UI_JS
+ # The promoted-row guard is the only thing that changed; the rest of
+ # the selector must remain intact.
+ head = ".tool-worklog-group:not([data-compression-card]),.tool-call-group:not([data-compression-card]),.tool-card-row:not([data-compression-card]),.agent-activity-thinking"
+ tail = ".wl-reason[data-worklog-reason-source=\"reasoning\"]"
+ assert head in UI_JS
+ assert tail in UI_JS
+
+
+def test_mcp_tool_names_appear_in_event_rows():
+ """MCP (Model Context Protocol) tools are namespaced as
+ mcp____. Their full name must be preserved on the row's
+ data-tool-name attribute, the header must use a friendly short form
+ (e.g. github/create_issue), and a plug-style icon must be used when
+ no canonical Hermes icon matches."""
+ # toolIcon: plug icon for mcp__ / mcp. prefixed names.
+ assert "startsWith('mcp__')" in UI_JS
+ assert "startsWith('mcp.')" in UI_JS
+ assert "li('plug')" in UI_JS
+ # _toolShortName reduces the namespace; the decorator must use it.
+ assert "function _toolShortName" in UI_JS
+ decorator_block = UI_JS[UI_JS.index("function _decorateTransparentEventRow"):UI_JS.index("function _setTransparentRowsExpanded")]
+ assert "_toolShortName(name)" in decorator_block
+ # buildToolCard preserves the full tool name on data-tool-name.
+ assert 'dataset.toolName' in UI_JS or 'row.dataset.toolName' in UI_JS
+
+
+def test_transparent_turn_header_is_collapsible():
+ """The Hermes chat name tag (assistant role label) must be clickable
+ in transparent mode to collapse/expand the entire event stack
+ underneath. A chevron is appended to the role to telegraph the
+ affordance; toggling flips data-transparent-turn-collapsed on the
+ turn and the CSS collapses the blocks body via max-height."""
+ assert "function _wireTransparentTurnToggle" in UI_JS
+ assert "data-transparent-turn-collapsed" in UI_JS
+ assert "transparent-turn-chevron" in UI_JS
+ # The CSS must collapse the blocks when the data attribute is set.
+ assert 'data-transparent-turn-collapsed="1"] .transparent-event-row' in STYLE_CSS
+ # The chevron rotates on collapse.
+ assert "transparent-turn-chevron" in STYLE_CSS
+ assert "transform:rotate(-90deg)" in STYLE_CSS
+
+
+def test_old_event_fading_medium_to_low():
+ """Older transparent event rows must fade to lower opacity (medium
+ → low) so the user's eye lands on the most recent activity. The
+ fade is per-turn and per-row position with a floor at 0.32."""
+ assert "function _applyTransparentRowFading" in UI_JS
+ # CSS steps from medium (~0.82) to low (~0.32).
+ assert 'data-transparent-fade="1"' in STYLE_CSS
+ assert 'data-transparent-fade="5"' in STYLE_CSS
+ assert "opacity:.32" in STYLE_CSS
+ # Hover restores full opacity.
+ assert ".transparent-event-row[data-transparent-fade]:hover{opacity:1" in STYLE_CSS
+
+
+def test_transparent_turn_footer_shows_elapsed_tokens_ttft_status():
+ """The bottom-of-turn footer mirrors the live run-status line for
+ settled turns: duration, first-token time, token usage, and final
+ status. Only renders for turns that have transparent event rows."""
+ assert "function _renderTransparentTurnFooter" in UI_JS
+ assert "function _formatFirstToken" in UI_JS
+ assert "transparent-turn-footer" in STYLE_CSS
+ assert "lf-ttft" in STYLE_CSS
+ assert "lf-time" in STYLE_CSS
+ assert "lf-status" in STYLE_CSS
+ # The footer must read duration, TTFT, and tokens from the message.
+ assert "_firstTokenMs" in UI_JS
+ assert "_formatFirstToken(msg._firstTokenMs)" in UI_JS
+ assert "_formatTurnDuration(msg._turnDuration)" in UI_JS
+ # i18n key for the TTFT tooltip.
+ assert "first_token_time" in I18N_JS
+
+
+def test_transparent_turn_wiring_runs_after_per_turn_duration_block():
+ """The turn-level wiring (toggle + fading + footer) must run AFTER
+ the per-turn duration block so the footer can reuse the computed
+ duration / tokens / TTFT for each settled turn."""
+ wiring_idx = UI_JS.index("// Transparent mode per-turn wiring:")
+ duration_block_end = UI_JS.index("}", UI_JS.index("targetFoot.classList.add('msg-foot-with-usage')"))
+ assert duration_block_end != -1
+ assert wiring_idx > duration_block_end, (
+ "transparent turn wiring must run after the per-turn duration block "
+ "so the footer can reuse computed duration/tokens/TTFT"
+ )
+
+
+def test_three_d_progress_bar_attached_to_each_event_row():
+ """Each transparent event card has a 3D progress bar at its bottom
+ edge. The bar shimmers while the tool is running and fills to 100%
+ when it completes. Drawn as a 1px highlight + 1px shadow for the
+ 3D ridge effect, and uses a step-break pseudo-element between
+ consecutive rows so the stream reads as discrete steps."""
+ assert "function _attachProgressBar" in UI_JS
+ # The decorator wires the progress bar at the end of the function.
+ assert "_attachProgressBar(row, opts)" in UI_JS
+ # CSS: bar has a 3D ridge (::after highlight) and a shimmer keyframe.
+ assert ".transparent-event-progress" in STYLE_CSS
+ assert "transparent-progress-shimmer" in STYLE_CSS
+ assert "@keyframes transparent-progress-shimmer" in STYLE_CSS
+ # The bar lives at the bottom of the card with a 12px radius to match
+ # the card's bottom-left and bottom-right corners.
+ assert "border-radius:0 0 12px 12px" in STYLE_CSS
+ # The 3D ridge highlight.
+ assert "transparent-event-progress::after" in STYLE_CSS
+ # Step break: between two consecutive event rows, a soft gradient line.
+ assert ".transparent-event-row + .transparent-event-row::before" in STYLE_CSS
+
+
+def test_copy_button_position_is_stable_and_dedup_handles_legacy_template():
+ """The copy button uses flex `order:9` so its position is stable
+ regardless of which other elements (status, preview) are in the
+ header. The toggle uses `order:10` so copy + toggle cluster at the
+ right edge in a predictable order. _attachCopyButton also reuses
+ the legacy .thinking-copy-btn baked into the thinking-card HTML
+ template, preventing duplicate copy buttons in the thinking box."""
+ # Flex order pinning.
+ assert "order:9" in STYLE_CSS
+ assert "order:10" in STYLE_CSS
+ # Dedup: _attachCopyButton checks for both .transparent-event-copy
+ # and .thinking-copy-btn.
+ assert "'.transparent-event-copy,.thinking-copy-btn'" in UI_JS
+ # The function normalises the class so CSS treats them identically.
+ assert "existing.classList.add('transparent-event-copy')" in UI_JS
+
+
+def test_increased_transparency_and_softened_edges():
+ """Background opacity is slightly lower (22% accent for tool, 30% for
+ thinking vs 28%/38% before) and the border is softer (12% accent
+ mix). Edges are softer: 12px border-radius, lower box-shadow
+ opacity, and the hover/expanded shadows are gentler."""
+ # Softer background.
+ assert "color-mix(in srgb,var(--accent-bg) 22%,transparent)" in STYLE_CSS
+ # Softer border.
+ assert "color-mix(in srgb,var(--accent) 12%,var(--border-subtle))" in STYLE_CSS
+ # Softer shadow.
+ assert "rgba(0,0,0,.03)" in STYLE_CSS
+ # 12px edges.
+ assert "border-radius:12px" in STYLE_CSS
+
+
+def test_smaller_tool_icons_and_reduced_font_sizes():
+ """Tool icons are constrained to 11px (was 14px), and the tool name
+ font is 11.5px (was 12.5px — reduced by two notches). The arg-key
+ font is 10px (was 11px). Status badge gets a pill background and
+ letter-spacing for visual distinction from the copy button."""
+ # Icon wrapper size.
+ assert "width:14px;height:14px" in STYLE_CSS
+ # Inner SVG size.
+ assert "width:11px;height:11px" in STYLE_CSS
+ # Tool name font 11.5px (down from 12.5px).
+ assert "font-size:11.5px" in STYLE_CSS
+ # Status badge: pill background + letter-spacing.
+ assert "border-radius:6px" in STYLE_CSS
+ assert "letter-spacing:.02em" in STYLE_CSS
diff --git a/tests/test_sprint49.py b/tests/test_sprint49.py
index a76091dde..4ce1dd59f 100644
--- a/tests/test_sprint49.py
+++ b/tests/test_sprint49.py
@@ -63,6 +63,7 @@ def test_last_assistant_keeps_usage_visible_and_reveals_time_and_actions_on_hove
assert (
"targetFoot.insertBefore(usage, targetFoot.firstChild);" in UI_JS
or "targetFoot.insertBefore(fragments[i], targetFoot.firstChild);" in UI_JS
+ or "targetFoot.insertBefore(fragments[i], firstChild);" in UI_JS
)
assert ".assistant-turn .msg-foot-with-usage," in UI_CSS
assert ".msg-row[data-role=\"assistant\"] .msg-foot-with-usage {\n opacity: 1;" in UI_CSS