From 922efdf234dffeed4013539b65402ab7559a5a93 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 10 Jun 2026 23:46:39 +0000 Subject: [PATCH] feat(outline): opt-in conversation outline panel (#2124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in, default-off, desktop-only floating panel that lists the user's questions in a conversation as a numbered jump-list (click to scroll + flash the message). Gated behind a Settings → Preferences toggle (show_conversation_outline) wired through the existing show_* preference boot/load/autosave/save paths. Review follow-ups applied on absorb: - Outline is now strictly chat-only: leaving the chat view (Settings, Tasks, Insights, …) hides the toggle button AND closes the panel; returning to chat restores the toggle (panel stays closed until re-opened). Implemented by gating _outlineAllowed() on the active panel and re-evaluating via a MutationObserver on the
showing- class (switchPanel is a global fn declaration that can't be reliably wrapped from this script). - Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector) outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually hid the panel — the × close button and auto-close had no visual effect. Added #outlinePanelWrapper[hidden]{display:none;}. - Regression tests for both. Closes #2124. Co-authored-by: Rod Boev --- api/config.py | 2 + static/boot.js | 12 +- static/i18n.js | 66 ++++++ static/index.html | 24 ++ static/outline.js | 291 ++++++++++++++++++++++++ static/panels.js | 31 ++- static/style.css | 62 +++++ tests/test_1003_preferences_autosave.py | 7 +- tests/test_issue2124_outline_panel.py | 117 ++++++++++ 9 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 static/outline.js create mode 100644 tests/test_issue2124_outline_panel.py diff --git a/api/config.py b/api/config.py index f4f2823e..5a90d8d5 100644 --- a/api/config.py +++ b/api/config.py @@ -5638,6 +5638,7 @@ _SETTINGS_DEFAULTS = { "send_key": "enter", # 'enter' or 'ctrl+enter' "show_token_usage": False, # show input/output token badge below assistant messages "show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media) + "show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel "hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons "show_tps": False, # show tokens-per-second chip in assistant message headers "fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect @@ -5820,6 +5821,7 @@ _SETTINGS_BOOL_KEYS = { "onboarding_completed", "show_token_usage", "show_quota_chip", + "show_conversation_outline", "hide_empty_state_suggestions", "show_tps", "fade_text_effect", diff --git a/static/boot.js b/static/boot.js index 771dba80..8fff26ed 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1779,8 +1779,14 @@ function applyBotName(){ const s=await api('/api/settings'); _bootSettings=s; window._sendKey=s.send_key||'enter'; + // Persist default workspace so the blank new-chat page can show it + // and workspace actions (New file/folder) work before the first session (#804). + if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace; window._showTokenUsage=!!s.show_token_usage; window._showQuotaChip=s.show_quota_chip===true; + window._showConversationOutline=s.show_conversation_outline===true; + document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true; applyEmptyStateSuggestionPref(); window._showTps=!!s.show_tps; @@ -1789,9 +1795,6 @@ function applyBotName(){ window._showPreviousMessagingSessions=!!s.show_previous_messaging_sessions; window._soundEnabled=!!s.sound_enabled; window._notificationsEnabled=!!s.notifications_enabled; - // Persist default workspace so the blank new-chat page can show it - // and workspace actions (New file/folder) work before the first session (#804). - if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace; window._whatsNewSummaryEnabled=!!s.whats_new_summary_enabled; window._showThinking=s.show_thinking!==false; window._simplifiedToolCalling=true; @@ -1887,6 +1890,9 @@ function applyBotName(){ window._sendKey='enter'; window._showTokenUsage=false; window._showQuotaChip=false; + window._showConversationOutline=false; + document.documentElement.dataset.conversationOutline='disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); window._hideEmptyStateSuggestions=false; applyEmptyStateSuggestionPref(); window._showTps=false; diff --git a/static/i18n.js b/static/i18n.js index efd04643..cbb3e6ed 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -630,6 +630,8 @@ const LOCALES = { settings_label_language: 'Language', settings_label_quota_chip: 'Show provider quota chip in composer', settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.', + settings_label_conversation_outline: 'Show conversation outline', + settings_desc_conversation_outline: 'Show a desktop-only jump list for user questions in the current conversation. Off by default.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Show token usage', @@ -1406,6 +1408,10 @@ const LOCALES = { checkpoint_diff_title: 'Changes in checkpoint', checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, + // ── Conversation Outline (#2124) ── + outline_title: 'Outline', + outline_empty: 'No questions yet.', + outline_loading: 'Loading…', }, it: { @@ -2034,6 +2040,8 @@ const LOCALES = { settings_label_language: 'Lingua', settings_label_quota_chip: 'Mostra il chip della quota del provider nel compositore', settings_desc_quota_chip: "Mostra un indicatore di quota residua (es. saldo crediti OpenRouter) nel piè di pagina del compositore. Predefinito disattivato. Visibile solo su schermi larghi (≥1400px) quando attivato, per mantenere il compositore non affollato su laptop e desktop standard.", + settings_label_conversation_outline: 'Mostra struttura conversazione', + settings_desc_conversation_outline: 'Mostra un elenco di salto solo desktop per le domande utente nella conversazione corrente. Disattivato per impostazione predefinita.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Mostra uso token', @@ -2802,6 +2810,9 @@ const LOCALES = { checkpoint_diff_title: 'Modifiche nel checkpoint', checkpoint_diff_no_changes: 'Nessuna differenza trovata tra questo checkpoint e il workspace attuale.', checkpoint_diff_files_changed: (n) => `${n} file modificat${n === 1 ? 'o' : 'i'}`, + outline_title: 'Struttura', + outline_empty: 'Nessuna domanda ancora.', + outline_loading: 'Caricamento…', }, ja: { @@ -3430,6 +3441,8 @@ const LOCALES = { settings_label_language: '言語', settings_label_quota_chip: 'コンポーザーにプロバイダーのクォータチップを表示', settings_desc_quota_chip: 'コンポーザーのフッターに残りクォータインジケーター(例: OpenRouter のクレジット残高)を表示します。デフォルトはオフ。有効にした場合、ラップトップや標準デスクトップの幅でコンポーザーが混雑しないよう、ワイドディスプレイ(≥1400px)でのみ表示されます。', + settings_label_conversation_outline: '会話アウトラインを表示', + settings_desc_conversation_outline: '現在の会話内のユーザー質問へ移動するデスクトップ専用リストを表示します。デフォルトはオフです。', settings_label_hide_suggestions: '新規チャットの候補を非表示', settings_desc_hide_suggestions: '空の新規チャット画面に表示されるデフォルトの候補ボタン3つを非表示にして、誤タップを防ぎます。', settings_label_token_usage: 'トークン使用量を表示', @@ -4203,6 +4216,9 @@ const LOCALES = { checkpoint_diff_title: 'チェックポイントの変更内容', checkpoint_diff_no_changes: 'このチェックポイントと現在のワークスペースの間に差分はありません。', checkpoint_diff_files_changed: (n) => `${n} 件のファイルが変更されました`, + outline_title: 'アウトライン', + outline_empty: 'まだ質問はありません。', + outline_loading: '読み込み中…', }, ru: { @@ -4618,6 +4634,8 @@ const LOCALES = { settings_label_language: 'Язык', settings_label_quota_chip: 'Показывать чип квоты провайдера в композиторе', settings_desc_quota_chip: 'Отображает фоновый индикатор остатка квоты (например, баланс кредитов OpenRouter) в подвале композитора. По умолчанию отключено. Виден только на широких экранах (≥1400px) при включении, чтобы не загромождать композитор на ноутбуках и стандартных мониторах.', + settings_label_conversation_outline: 'Показывать структуру беседы', + settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Показывать использование токенов', @@ -5541,6 +5559,9 @@ const LOCALES = { settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate + outline_title: 'Outline', // TODO: translate + outline_empty: 'No questions yet.', // TODO: translate + outline_loading: 'Loading…', // TODO: translate }, es: { @@ -5941,6 +5962,8 @@ const LOCALES = { settings_label_language: 'Idioma', settings_label_quota_chip: 'Mostrar el chip de cuota del proveedor en el compositor', settings_desc_quota_chip: 'Muestra un indicador ambiental de cuota restante (por ejemplo, saldo de crédito de OpenRouter) en el pie del compositor. Predeterminado: desactivado. Solo visible en pantallas anchas (≥1400px) cuando se activa, para mantener el compositor despejado en portátiles y monitores estándar.', + settings_label_conversation_outline: 'Mostrar esquema de conversación', + settings_desc_conversation_outline: 'Muestra una lista de salto solo en escritorio para las preguntas del usuario en la conversación actual. Desactivado de forma predeterminada.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Mostrar uso de tokens', @@ -6873,6 +6896,9 @@ const LOCALES = { voice_mode_toggle: 'Voice mode', // TODO: translate voice_mode_toggle_active: 'Exit voice mode', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate + outline_title: 'Esquema', + outline_empty: 'Aún no hay preguntas.', + outline_loading: 'Cargando…', }, de: { @@ -7258,6 +7284,8 @@ const LOCALES = { settings_label_language: 'Sprache', settings_label_quota_chip: 'Anbieter-Kontingent-Chip im Editor anzeigen', settings_desc_quota_chip: 'Zeigt einen Hintergrund-Indikator des verbleibenden Kontingents (z. B. OpenRouter-Guthaben) in der Editor-Fußzeile an. Standardmäßig deaktiviert. Bei Aktivierung nur auf breiten Bildschirmen (≥1400px) sichtbar, damit der Editor auf Laptops und Standard-Desktops übersichtlich bleibt.', + settings_label_conversation_outline: 'Konversationsgliederung anzeigen', + settings_desc_conversation_outline: 'Zeigt eine Desktop-Sprungliste für Benutzerfragen in der aktuellen Konversation. Standardmäßig deaktiviert.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Token-Verbrauch anzeigen', @@ -8209,6 +8237,9 @@ const LOCALES = { voice_mode_toggle: 'Voice mode', // TODO: translate voice_mode_toggle_active: 'Exit voice mode', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate + outline_title: 'Gliederung', + outline_empty: 'Noch keine Fragen.', + outline_loading: 'Laden…', }, zh: { @@ -8628,6 +8659,8 @@ const LOCALES = { settings_label_language: '语言', settings_label_quota_chip: '在编辑器中显示供应商配额标签', settings_desc_quota_chip: '在编辑器底部显示剩余配额指示器(如 OpenRouter 信用余额)。默认关闭。启用时仅在宽屏(≥1400px)显示,以保持笔记本和标准桌面屏幕上编辑器的整洁。', + settings_label_conversation_outline: '显示会话大纲', + settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: '显示 token 用量', @@ -9538,6 +9571,9 @@ const LOCALES = { voice_mode_toggle: '语音模式', voice_mode_toggle_active: '退出语音模式', subagent_children: '子代理会话', + outline_title: '大纲', + outline_empty: '暂无问题。', + outline_loading: '加载中…', }, // Traditional Chinese (zh-Hant) @@ -10159,6 +10195,8 @@ const LOCALES = { settings_label_language: '語言', settings_label_quota_chip: '在編輯器中顯示供應商配額標籤', settings_desc_quota_chip: '在編輯器底部顯示剩餘配額指示器(如 OpenRouter 點數餘額)。預設關閉。啟用時僅在寬螢幕(≥1400px)顯示,以保持筆記型電腦和標準桌面螢幕上編輯器的整潔。', + settings_label_conversation_outline: '顯示對話大綱', + settings_desc_conversation_outline: '顯示僅限桌面的跳轉清單,用於跳轉到目前對話中的使用者問題。預設關閉。', settings_label_hide_suggestions: '隱藏新聊天建議', settings_desc_hide_suggestions: '隱藏空白新聊天畫面上的三個預設建議按鈕,避免誤觸。', settings_label_token_usage: '顯示 token 用量', @@ -10935,6 +10973,9 @@ const LOCALES = { checkpoint_diff_title: '檢查點中的變更', checkpoint_diff_no_changes: '這個檢查點與目前工作區沒有差異。', checkpoint_diff_files_changed: (n) => `已變更 ${n} 個檔案`, + outline_title: '大綱', + outline_empty: '尚無問題。', + outline_loading: '載入中…', }, @@ -11445,6 +11486,8 @@ const LOCALES = { settings_label_language: 'Idioma', settings_label_quota_chip: 'Mostrar o chip de cota do provedor no compositor', settings_desc_quota_chip: 'Exibe um indicador ambiente de cota restante (por exemplo, saldo de crédito do OpenRouter) no rodapé do compositor. Desativado por padrão. Visível apenas em telas largas (≥1400px) quando ativado, para manter o compositor livre em laptops e monitores padrão.', + settings_label_conversation_outline: 'Mostrar estrutura da conversa', + settings_desc_conversation_outline: 'Mostra uma lista de atalhos somente no desktop para as perguntas do usuário na conversa atual. Desativado por padrão.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Mostrar uso de tokens', @@ -12149,6 +12192,9 @@ const LOCALES = { sign_out_failed: 'Falha ao sair: ', auth_disabled: 'Autenticação desativada — proteção por senha removida', disable_auth_confirm_title: 'Desativar proteção por senha', + outline_title: 'Esboço', + outline_empty: 'Ainda não há perguntas.', + outline_loading: 'Carregando…', }, ko: { offline_title: '연결이 끊겼습니다', @@ -12742,6 +12788,8 @@ const LOCALES = { settings_label_language: '언어', settings_label_quota_chip: '작성기에 공급자 할당량 칩 표시', settings_desc_quota_chip: '작성기 푸터에 남은 할당량 표시기(예: OpenRouter 크레딧 잔액)를 표시합니다. 기본값은 끔. 활성화 시 노트북과 표준 데스크톱에서 작성기가 복잡해지지 않도록 와이드 디스플레이(≥1400px)에서만 표시됩니다.', + settings_label_conversation_outline: '대화 개요 표시', + settings_desc_conversation_outline: '현재 대화의 사용자 질문으로 이동하는 데스크톱 전용 목록을 표시합니다. 기본값은 꺼짐입니다.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: '토큰 사용량 표시', @@ -13534,6 +13582,9 @@ const LOCALES = { voice_mode_toggle: 'Voice mode', // TODO: translate voice_mode_toggle_active: 'Exit voice mode', // TODO: translate subagent_children: 'Subagent sessions', // TODO: translate + outline_title: '개요', + outline_empty: '아직 질문이 없습니다.', + outline_loading: '로딩 중…', }, fr: { @@ -14056,6 +14107,8 @@ const LOCALES = { settings_label_language: 'Langue', settings_label_quota_chip: 'Afficher la pastille de quota du fournisseur dans le compositeur', settings_desc_quota_chip: "Affiche un indicateur ambiant de quota restant (par ex. solde de crédit OpenRouter) dans le pied du compositeur. Désactivé par défaut. Visible uniquement sur les écrans larges (≥1400px) lorsqu'activé, pour garder le compositeur dégagé sur les ordinateurs portables et les bureaux standard.", + settings_label_conversation_outline: 'Afficher le plan de conversation', + settings_desc_conversation_outline: "Affiche une liste de saut réservée au bureau pour les questions utilisateur de la conversation actuelle. Désactivé par défaut.", settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', settings_label_token_usage: 'Afficher l\'utilisation du jeton', @@ -14860,6 +14913,9 @@ const LOCALES = { usage_total: 'Total de tokens', usage_unknown: 'inconnu', workspace_auto_create_folder: 'Créer le dossier s\'il n\'existe pas', + outline_title: 'Plan', + outline_empty: 'Pas encore de questions.', + outline_loading: 'Chargement…', }, tr: { @@ -15471,6 +15527,8 @@ const LOCALES = { settings_label_language: 'Dil', settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster', settings_desc_quota_chip: 'Besteci altbilgisinde bir ortam kalan kota göstergesini (örn. OpenRouter kredi bakiyesi) görüntüler. Varsayılan kapalı. Besteciyi dizüstü bilgisayar ve standart masaüstü genişliklerinde düzenli tutmak için yalnızca etkinleştirildiğinde geniş ekranlarda (≥1400 piksel) görünür.', + settings_label_conversation_outline: 'Konuşma ana hattını göster', + settings_desc_conversation_outline: 'Geçerli konuşmadaki kullanıcı soruları için yalnızca masaüstünde görünen bir atlama listesi gösterir. Varsayılan kapalıdır.', settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.', settings_label_token_usage: 'Jeton kullanımını göster', @@ -16249,6 +16307,9 @@ const LOCALES = { settings_desc_api_redact: 'Kendi sunucunuzda şeffaflık için devre dışı bırakılabilir (paylaşımlı örneklerde önerilmez).', settings_label_api_redact: 'API yanıtlarında hassas verileri gizle', subagent_children: 'Alt agent oturumları', + outline_title: 'Ana Hat', + outline_empty: 'Henüz soru yok.', + outline_loading: 'Yükleniyor…', @@ -16878,6 +16939,8 @@ const LOCALES = { settings_label_language: 'Język', settings_label_quota_chip: 'Pokaż wskaźnik limitu dostawcy w polu wprowadzania', settings_desc_quota_chip: 'Wyświetla subtelny wskaźnik pozostałego limitu (np. saldo kredytów OpenRouter) w stopce pola wprowadzania. Domyślnie wyłączone. Widoczne tylko na szerokich ekranach (≥1400px), aby utrzymać pole wprowadzania czytelnym na laptopach i standardowych monitorach.', + settings_label_conversation_outline: 'Pokaż zarys konwersacji', + settings_desc_conversation_outline: 'Pokazuje desktopową listę skrótów do pytań użytkownika w bieżącej konwersacji. Domyślnie wyłączone.', settings_label_hide_suggestions: 'Ukryj sugestie nowej konwersacji', settings_desc_hide_suggestions: 'Ukryj trzy domyślne przyciski sugestii na pustym ekranie nowej konwersacji, aby zapobiec przypadkowym kliknięciom.', settings_label_token_usage: 'Pokaż zużycie tokenów', @@ -17154,6 +17217,9 @@ const LOCALES = { settings_desc_token_usage: 'Wyświetla liczbę tokenów wejściowych/wyjściowych pod każdą odpowiedzią asystenta. Można też przełączyć za pomocą /usage.', settings_label_api_redact: 'Ukrywaj poufne dane w odpowiedziach API', settings_desc_api_redact: 'Użytkownicy korzystający z hostingu własnego mogą wyłączyć to dla przejrzystości (niezalecane dla współdzielonych instancji).', + outline_title: 'Zarys', + outline_empty: 'Nie ma jeszcze pytań.', + outline_loading: 'Ładowanie…', settings_sidebar_density_compact: 'Kompaktowa', settings_sidebar_density_detailed: 'Szczegółowa', settings_desc_sidebar_density: 'Kontroluje, ile metadanych wyświetla lista sesji na lewym pasku bocznym.', diff --git a/static/index.html b/static/index.html index 686d4303..ec5af0d0 100644 --- a/static/index.html +++ b/static/index.html @@ -1146,6 +1146,13 @@
Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.
+
+ +
Show a desktop-only jump list for user questions in the current conversation. Off by default.
+
+ + + diff --git a/static/outline.js b/static/outline.js new file mode 100644 index 00000000..213dd5ed --- /dev/null +++ b/static/outline.js @@ -0,0 +1,291 @@ +// ── Conversation Outline Panel (#2124) ─────────────────────────────────────── +// Floating panel listing user messages as jump targets. +// _outlineSid guards against stale renders when the user switches sessions. + +'use strict'; + +(function() { + +let _outlineSid = null; // session id the panel was last built for +let _panelOpen = false; // whether the panel is currently visible +let _outlineResizeObserver = null; +let _outlineWorkspaceObserver = null; + +// Returns the current session id, or null if no session is loaded. +function _currentSid() { + return (S && S.session && S.session.session_id) || null; +} + +function _outlineAllowed() { + const compact = window.matchMedia && window.matchMedia('(max-width:900px)').matches; + // The outline is a chat-view affordance only — never show the toggle or panel + // while another main panel (settings, tasks, insights, …) is active. _currentPanel + // is owned by panels.js; treat an undefined/absent value as the chat default. + const panel = (typeof _currentPanel === 'undefined') ? 'chat' : (_currentPanel || 'chat'); + return window._showConversationOutline === true && !compact && panel === 'chat'; +} + +function _syncOutlinePosition() { + const root = document.documentElement; + const panel = document.querySelector('.rightpanel'); + const open = root.dataset.workspacePanel === 'open'; + const width = open && panel ? Math.max(0, Math.round(panel.offsetWidth || 0)) : 0; + root.style.setProperty('--outline-workspace-offset', width + 'px'); +} + +function applyConversationOutlinePreference() { + const toggle = document.getElementById('outlineToggleBtn'); + const wrapper = document.getElementById('outlinePanelWrapper'); + const enabled = _outlineAllowed(); + document.documentElement.dataset.conversationOutline = enabled ? 'enabled' : 'disabled'; + _syncOutlinePosition(); + if (toggle) toggle.hidden = !enabled; + if (!enabled) { + _panelOpen = false; + if (wrapper) wrapper.hidden = true; + } +} + +function _expandOutlineRenderWindow() { + if (typeof _currentMessageRenderWindowSize !== 'function' || + typeof _messageRenderableMessageCount !== 'function' || + typeof _messageRenderWindowSize === 'undefined') return; + _messageRenderWindowSize = Math.max( + _currentMessageRenderWindowSize(), + _messageRenderableMessageCount() + ); +} + +function _ensureOutlineMessagesLoaded(sid) { + if (!sid || S.busy || S.activeStreamId) return Promise.resolve(false); + if (typeof _messagesTruncated === 'undefined' || !_messagesTruncated) { + return Promise.resolve(false); + } + if (typeof _ensureAllMessagesLoaded !== 'function') return Promise.resolve(false); + return _ensureAllMessagesLoaded().then(function() { + if (!S.session || S.session.session_id !== sid) return false; + _expandOutlineRenderWindow(); + return true; + }).catch(function() { + return false; + }); +} + +// Extracts the first 60 visible characters from a message content value. +function _excerptText(content) { + let text = ''; + if (Array.isArray(content)) { + text = content + .filter(p => p && p.type === 'text') + .map(p => p.text || p.content || '') + .join(' '); + } else { + text = String(content || ''); + } + text = text.trim().replace(/\s+/g, ' '); + return text.length > 60 ? text.slice(0, 60) + '…' : text; +} + +// Scrolls to a user message row identified by its rawIdx and flashes it. +function _jumpToMessage(rawIdx) { + const sid = _currentSid(); + if (!sid) return; + + const rowId = 'msg-user-' + rawIdx; + const row = document.getElementById(rowId); + if (row) { + row.scrollIntoView({ block: 'center', behavior: 'smooth' }); + _flashRow(row); + return; + } + + // Row is outside the render window — reload the full session and retry. + if (typeof api !== 'function') return; + if (S.busy || S.activeStreamId) return; + api('/api/session?session_id=' + encodeURIComponent(sid) + + '&messages=1&resolve_model=0&msg_limit=9999') + .then(function(data) { + if (!data || !data.session) return; + if (!S.session || S.session.session_id !== sid) return; // session switched + S.messages = data.session.messages || []; // populate S + _expandOutlineRenderWindow(); + if (typeof renderMessages === 'function') renderMessages({ preserveScroll: true }); + window.setTimeout(function() { + if (!S.session || S.session.session_id !== sid) return; + const r = document.getElementById('msg-user-' + rawIdx); + if (r) { r.scrollIntoView({ block: 'center', behavior: 'smooth' }); _flashRow(r); } + }, 120); + }) + .catch(function() {}); +} + +// Brief highlight flash on a message row after jumping. +function _flashRow(row) { + if (!row) return; + row.classList.remove('outline-jump-flash'); + void row.offsetWidth; // reflow to restart animation + row.classList.add('outline-jump-flash'); + window.setTimeout(function() { row.classList.remove('outline-jump-flash'); }, 1200); +} + +// Builds the list of user messages from S.messages. +// Returns [{rawIdx, label, excerpt}, …] for every user message with content. +function _buildEntries() { + const msgs = (S && S.messages) || []; + const entries = []; + let userN = 0; + + for (let i = 0; i < msgs.length; i++) { + const m = msgs[i]; + if (!m || m.role !== 'user') continue; + const text = _excerptText(m.content); + if (!text) continue; + userN++; + entries.push({ rawIdx: i, label: userN, excerpt: text }); + } + return entries; +} + +// Renders the panel body. Called every time the panel opens or session changes. +function _renderPanel() { + const panel = document.getElementById('outlinePanel'); + if (!panel) return; + + const sid = _currentSid(); + + // Session-scoped staleness guard. + if (!sid) { + panel.innerHTML = '

' + t('outline_empty') + '

'; + _outlineSid = null; + return; + } + + if (!S.messages) { + panel.innerHTML = '

' + t('outline_loading') + '

'; + _outlineSid = sid; + return; + } + + _outlineSid = sid; + const entries = _buildEntries(); + + if (!entries.length) { + panel.innerHTML = '

' + t('outline_empty') + '

'; + return; + } + + const items = entries.map(function(e) { + return ''; + }); + + panel.innerHTML = items.join(''); +} + +// Simple HTML-escape for entry text. +function _escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Opens or closes the outline panel. +function toggleOutlinePanel() { + if (!_outlineAllowed()) { + applyConversationOutlinePreference(); + return; + } + _panelOpen = !_panelOpen; + const wrapper = document.getElementById('outlinePanelWrapper'); + if (!wrapper) return; + + if (_panelOpen) { + _syncOutlinePosition(); + wrapper.hidden = false; + const sid = _currentSid(); + const panel = document.getElementById('outlinePanel'); + if (panel) panel.innerHTML = '

' + t('outline_loading') + '

'; + _ensureOutlineMessagesLoaded(sid).then(function() { + if (!_panelOpen || _currentSid() !== sid) return; + _renderPanel(); + // Keep rendered data fresh after every renderMessages() call. + _outlineSid = _currentSid(); + }); + } else { + wrapper.hidden = true; + } +} + +// Jump target exposed on window so inline onclick handlers can reach it. +window._outlineJump = _jumpToMessage; +window.applyConversationOutlinePreference = applyConversationOutlinePreference; + +// Re-render after renderMessages() if the panel is open and the session +// changed or new messages arrived since the last render. +(function _hookRenderMessages() { + if (typeof window._outlineRenderHooked !== 'undefined') return; + + const _orig = window.renderMessages; + if (typeof _orig !== 'function') { + // renderMessages may not be defined yet — retry after DOMContentLoaded. + if (!window._outlineRenderHookPending) { + window._outlineRenderHookPending = true; + document.addEventListener('DOMContentLoaded', _hookRenderMessages, { once: true }); + } + return; + } + window._outlineRenderHooked = true; + window._outlineRenderHookPending = false; + window.renderMessages = function() { + const result = _orig.apply(this, arguments); + if (_panelOpen) { + const sid = _currentSid(); + if (sid && (sid !== _outlineSid || (S.messages || []).length > 0)) { + _renderPanel(); + } + } + return result; + }; +})(); + +// Expose public API. +window.toggleOutlinePanel = toggleOutlinePanel; + +document.addEventListener('DOMContentLoaded', function() { + applyConversationOutlinePreference(); + const root = document.documentElement; + const rightPanel = document.querySelector('.rightpanel'); + if (rightPanel && typeof ResizeObserver !== 'undefined' && !_outlineResizeObserver) { + _outlineResizeObserver = new ResizeObserver(_syncOutlinePosition); + _outlineResizeObserver.observe(rightPanel); + } + if (!_outlineWorkspaceObserver) { + _outlineWorkspaceObserver = new MutationObserver(applyConversationOutlinePreference); + _outlineWorkspaceObserver.observe(root, { + attributes: true, + attributeFilter: ['data-workspace-panel'] + }); + // Also re-evaluate when the active main panel changes. switchPanel() is a + // global function declaration (called via inline onclick), so it can't be + // reliably wrapped from this script; instead we watch the `showing-` + // class it toggles on
. The outline is a chat-only affordance, so this + // hides the toggle + closes the panel when leaving chat (settings, tasks, + // insights, …) and restores the toggle on return to chat. _outlineAllowed() + // reads _currentPanel for the actual gate; this observer just triggers it. + const mainEl = document.querySelector('main.main'); + if (mainEl) { + _outlineWorkspaceObserver.observe(mainEl, { + attributes: true, + attributeFilter: ['class'] + }); + } + } +}); +window.addEventListener('resize', applyConversationOutlinePreference); + +})(); diff --git a/static/panels.js b/static/panels.js index ecf35493..75f77605 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6301,6 +6301,8 @@ function _preferencesPayloadFromUi(){ if(showUsageCb) payload.show_token_usage=showUsageCb.checked; const showQuotaChipCb=$('settingsShowQuotaChip'); if(showQuotaChipCb) payload.show_quota_chip=showQuotaChipCb.checked; + const showConversationOutlineCb=$('settingsShowConversationOutline'); + if(showConversationOutlineCb) payload.show_conversation_outline=showConversationOutlineCb.checked; const hideSuggestionsCb=$('settingsHideSuggestions'); if(hideSuggestionsCb) payload.hide_empty_state_suggestions=hideSuggestionsCb.checked; const showTpsCb=$('settingsShowTps'); @@ -6397,6 +6399,11 @@ async function _autosavePreferencesSettings(payload){ window._hideEmptyStateSuggestions=!!(saved&&saved.hide_empty_state_suggestions); if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref(); } + if(payload&&payload.show_conversation_outline!==undefined){ + window._showConversationOutline=!!(saved&&saved.show_conversation_outline); + document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); + } _settingsPreferencesAutosaveRetryPayload=null; _setPreferencesAutosaveStatus('saved'); // Only clear the global dirty flag and hide the unsaved-changes bar when @@ -6618,6 +6625,19 @@ async function loadSettingsPanel(){ _schedulePreferencesAutosave(); },{once:false}); } + const showConversationOutlineCb=$('settingsShowConversationOutline'); + if(showConversationOutlineCb){ + showConversationOutlineCb.checked=settings.show_conversation_outline===true; + window._showConversationOutline=showConversationOutlineCb.checked; + document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); + showConversationOutlineCb.addEventListener('change',()=>{ + _schedulePreferencesAutosave(); + window._showConversationOutline=showConversationOutlineCb.checked; + document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); + },{once:false}); + } const showTpsCb=$('settingsShowTps'); if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});} const pinnedLimitField=$('settingsPinnedSessionsLimit'); @@ -7727,10 +7747,13 @@ async function deletePasskey(id){ } function _applySavedSettingsUi(saved, body, opts){ - const {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts; + const {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts; window._sendKey=sendKey||'enter'; window._showTokenUsage=showTokenUsage; window._showQuotaChip=showQuotaChip===true; + window._showConversationOutline=showConversationOutline===true; + document.documentElement.dataset.conversationOutline=window._showConversationOutline?'enabled':'disabled'; + if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); window._showTps=showTps; window._fadeTextEffect=!!fadeTextEffect; window._showCliSessions=showCliSessions; @@ -8055,6 +8078,7 @@ async function saveSettings(andClose){ const sendKey=($('settingsSendKey')||{}).value; const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked; const showQuotaChip=!!($('settingsShowQuotaChip')||{}).checked; + const showConversationOutline=!!($('settingsShowConversationOutline')||{}).checked; const showTps=!!($('settingsShowTps')||{}).checked; const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked; const showCliSessions=!!($('settingsShowCliSessions')||{}).checked; @@ -8079,6 +8103,7 @@ async function saveSettings(andClose){ body.language=language; body.show_token_usage=showTokenUsage; body.show_quota_chip=showQuotaChip===true; + body.show_conversation_outline=showConversationOutline===true; body.show_tps=showTps; body.fade_text_effect=fadeTextEffect; body.terminal_auto_expand_on_output=!!($('settingsTerminalAutoExpand')||{}).checked; @@ -8114,7 +8139,7 @@ async function saveSettings(andClose){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } - _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); + _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated')); _settingsDirty=false; _resetSettingsPanelState(); @@ -8133,7 +8158,7 @@ async function saveSettings(andClose){ if(typeof showToast==='function') showToast('Failed to update default model — settings saved'); } } - _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); + _applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize}); showToast(t('settings_saved')); _settingsDirty=false; _resetSettingsPanelState(); diff --git a/static/style.css b/static/style.css index 641aa089..cbf76a6e 100644 --- a/static/style.css +++ b/static/style.css @@ -5366,3 +5366,65 @@ main.main.showing-logs > #mainLogs{display:flex;} display: block; } .interim-collapse-toggle:hover { text-decoration: underline; } + +/* ── Conversation Outline Panel (#2124) ─────────────────────────────────── */ +#outlineToggleBtn{ + position:fixed;bottom:120px;right:calc(var(--outline-workspace-offset, 0px) + 20px);z-index:9999; + width:38px;height:38px;border-radius:50%; + background:var(--surface);border:1px solid var(--border2); + color:var(--text);cursor:pointer; + display:none;align-items:center;justify-content:center; + box-shadow:0 2px 8px rgba(0,0,0,.18); + font-size:16px;line-height:1; + transition:background .15s ease,border-color .15s ease,color .15s ease; +} +html[data-conversation-outline="enabled"] #outlineToggleBtn:not([hidden]){display:flex;} +#outlineToggleBtn:hover{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);} +#outlinePanelWrapper{ + position:fixed;right:calc(var(--outline-workspace-offset, 0px) + 20px);bottom:170px;width:320px; + max-height:50vh;z-index:9998; + background:var(--surface);border:1px solid var(--border2); + border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.22); + display:flex;flex-direction:column;overflow:hidden; +} +/* The id selector's display:flex outranks the UA [hidden]{display:none}, so the + hidden attribute alone won't hide the panel — restore it explicitly. */ +#outlinePanelWrapper[hidden]{display:none;} +.outline-header{ + display:flex;align-items:center;justify-content:space-between; + padding:10px 12px 8px;border-bottom:1px solid var(--border2); + font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase; + letter-spacing:.04em;flex-shrink:0; +} +.outline-close-btn{ + background:none;border:none;cursor:pointer; + color:var(--muted);font-size:16px;line-height:1;padding:0 2px; +} +.outline-close-btn:hover{color:var(--text);} +#outlinePanel{overflow-y:auto;flex:1;} +.outline-entry{ + display:flex;align-items:baseline;gap:8px; + width:100%;padding:7px 12px; + background:none;border:none;border-bottom:1px solid var(--border-subtle); + cursor:pointer;text-align:left;color:var(--text); + transition:background .12s ease; +} +.outline-entry:last-child{border-bottom:none;} +.outline-entry:hover{background:var(--accent-bg);} +.outline-entry-num{ + font-size:11px;font-weight:600;color:var(--muted); + min-width:18px;flex-shrink:0; +} +.outline-entry-text{font-size:13px;line-height:1.4;word-break:break-word;} +.outline-empty{ + padding:16px 12px;font-size:13px;color:var(--muted);text-align:center; +} +/* Flash animation when jumping to a message */ +@keyframes outline-flash{ + 0%{background:var(--accent-bg-strong);} + 100%{background:transparent;} +} +.outline-jump-flash{animation:outline-flash 1.2s ease-out forwards;} +@media(max-width:900px){ + #outlineToggleBtn,#outlinePanelWrapper{display:none!important;} +} diff --git a/tests/test_1003_preferences_autosave.py b/tests/test_1003_preferences_autosave.py index 11e14978..5f8f8f13 100644 --- a/tests/test_1003_preferences_autosave.py +++ b/tests/test_1003_preferences_autosave.py @@ -38,6 +38,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [ ("settingsSendKey", "send_key"), ("settingsLanguage", "language"), ("settingsShowTokenUsage", "show_token_usage"), + ("settingsShowConversationOutline", "show_conversation_outline"), ("settingsShowTps", "show_tps"), ("settingsShowCliSessions", "show_cli_sessions"), ("settingsShowPreviousMessagingSessions", "show_previous_messaging_sessions"), @@ -53,8 +54,8 @@ PREFERENCE_FIELDS_AUTOSAVE = [ ] -def test_all_14_preference_fields_have_autosave_payload_entries(): - """_preferencesPayloadFromUi must include all 14 preference fields.""" +def test_all_preference_fields_have_autosave_payload_entries(): + """_preferencesPayloadFromUi must include every autosaved preference field.""" block = _function_block(PANELS_JS, "_preferencesPayloadFromUi") for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE: assert f"$('{dom_id}')" in block, \ @@ -64,7 +65,7 @@ def test_all_14_preference_fields_have_autosave_payload_entries(): def test_preference_fields_use_schedule_autosave_not_mark_dirty(): - """All 14 listener attachments (excluding bot_name's debounce wrapper) must + """All listener attachments (excluding bot_name's debounce wrapper) must use _schedulePreferencesAutosave. bot_name uses a wrapper but still eventually calls _schedulePreferencesAutosave.""" panel = _load_settings_panel_block() diff --git a/tests/test_issue2124_outline_panel.py b/tests/test_issue2124_outline_panel.py new file mode 100644 index 00000000..5253b212 --- /dev/null +++ b/tests/test_issue2124_outline_panel.py @@ -0,0 +1,117 @@ +"""Static-analysis tests for the conversation outline panel (issue #2124).""" + +from pathlib import Path +import re + +ROOT = Path(__file__).parent.parent +STATIC = ROOT / "static" +INDEX_HTML = (STATIC / "index.html").read_text(encoding="utf-8") +I18N_JS = (STATIC / "i18n.js").read_text(encoding="utf-8") +OUTLINE_JS = (STATIC / "outline.js").read_text(encoding="utf-8") +STYLE_CSS = (STATIC / "style.css").read_text(encoding="utf-8") +BOOT_JS = (STATIC / "boot.js").read_text(encoding="utf-8") +PANELS_JS = (STATIC / "panels.js").read_text(encoding="utf-8") +CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") + +# Number of locale blocks in i18n.js: en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr +LOCALE_COUNT = 12 + + +def test_outline_panel_html_and_i18n_contract(): + """The panel shell, script tag, and locale keys must be present.""" + assert 'src="static/outline.js?v=__WEBUI_VERSION__"' in INDEX_HTML + assert "outline.js?v=__WEBUI_VERSION__" in INDEX_HTML + assert re.search(r']+outline\.js\?v=__WEBUI_VERSION__[^>]+defer', INDEX_HTML) + assert re.search(r'id="outlineToggleBtn"[^>]*hidden', INDEX_HTML) + assert re.search(r'id="outlinePanelWrapper"[^>]*hidden', INDEX_HTML) + assert 'id="outlinePanel"' in INDEX_HTML + + for key in ( + "outline_title:", + "outline_empty:", + "outline_loading:", + "settings_label_conversation_outline:", + "settings_desc_conversation_outline:", + ): + assert I18N_JS.count(key) >= LOCALE_COUNT + + +def test_outline_setting_round_trip_contract(): + """The outline preference must default off and use the normal settings path.""" + assert '"show_conversation_outline": False' in CONFIG_PY + assert '"show_conversation_outline",' in CONFIG_PY + assert 'id="settingsShowConversationOutline"' in INDEX_HTML + assert "payload.show_conversation_outline=showConversationOutlineCb.checked;" in PANELS_JS + assert "settings.show_conversation_outline===true" in PANELS_JS + assert "body.show_conversation_outline=showConversationOutline===true;" in PANELS_JS + assert "window._showConversationOutline=s.show_conversation_outline===true" in BOOT_JS + assert "window._showConversationOutline=false" in BOOT_JS + + +def test_outline_navigation_and_long_session_contract(): + """Outline entries must come from session messages and recover off-window targets.""" + for marker in ( + "_outlineSid", + "window.toggleOutlinePanel", + "window._outlineJump", + "S.messages", + "'msg-user-'", + "/api/session", + "_ensureOutlineMessagesLoaded", + "_ensureAllMessagesLoaded()", + "_messagesTruncated", + "_expandOutlineRenderWindow()", + "_messageRenderWindowSize = Math.max(", + "renderMessages({ preserveScroll: true })", + "if (S.busy || S.activeStreamId) return;", + ): + assert marker in OUTLINE_JS + + +def test_outline_opt_in_layout_and_render_state_contract(): + """The enabled outline must stay desktop-only and avoid stale render states.""" + for marker in ( + "window._showConversationOutline === true", + "toggle.hidden = !enabled", + "wrapper.hidden = true", + "window.applyConversationOutlinePreference", + "matchMedia('(max-width:900px)')", + "--outline-workspace-offset", + "panel.offsetWidth", + "data-workspace-panel", + "window._outlineRenderHookPending", + "if (!S.messages) {", + ): + assert marker in OUTLINE_JS + + assert "#outlineToggleBtn,#outlinePanelWrapper{display:none!important;}" in STYLE_CSS + assert "right:calc(var(--outline-workspace-offset, 0px) + 20px)" in STYLE_CSS + assert "if (!S.messages || !S.messages.length)" not in OUTLINE_JS + + before_hooked = OUTLINE_JS.index("window._outlineRenderHooked = true") + before_wrapper = OUTLINE_JS.index("window.renderMessages = function") + missing_render_block = OUTLINE_JS.split("if (typeof _orig !== 'function')", 1)[1] + missing_render_block = missing_render_block.split("window._outlineRenderHooked = true", 1)[0] + assert before_hooked < before_wrapper + assert "window._outlineRenderHooked = true" not in missing_render_block + + +def test_outline_is_chat_only_and_closes_on_panel_switch(): + """The outline is a chat-view affordance: leaving chat must hide the toggle + AND close the panel, and returning to chat restores the toggle. (Auto-close + on panel switch — review follow-up.)""" + # _outlineAllowed() gates on the active panel, not just the setting + width. + assert "_currentPanel" in OUTLINE_JS + assert "panel === 'chat'" in OUTLINE_JS + # Re-evaluated on main-view changes: switchPanel() toggles `showing-` + # on
, and the MutationObserver watches that class to re-run the gate. + assert "main.main" in OUTLINE_JS + assert "attributeFilter: ['class']" in OUTLINE_JS + + +def test_outline_wrapper_hidden_attr_actually_hides(): + """The #outlinePanelWrapper id selector sets display:flex, which outranks the + UA [hidden]{display:none} rule — so the hidden attribute alone would NOT hide + the panel (the close button / auto-close set wrapper.hidden=true). An explicit + #outlinePanelWrapper[hidden]{display:none} rule restores the expected behavior.""" + assert "#outlinePanelWrapper[hidden]{display:none;}" in STYLE_CSS