feat(outline): opt-in conversation outline panel (#2124)

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 <main> showing-<panel> 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 <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes
2026-06-10 23:46:39 +00:00
parent b1a4750466
commit 922efdf234
9 changed files with 603 additions and 9 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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.',

View File

@@ -1146,6 +1146,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="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.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowConversationOutline" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_conversation_outline">Show conversation outline</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_conversation_outline">Show a desktop-only jump list for user questions in the current conversation. Off by default.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsShowTps" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -1500,6 +1507,7 @@
<script src="static/panels.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/onboarding.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
<script src="static/outline.js?v=__WEBUI_VERSION__" defer></script>
<!-- Kanban: create/rename board modal — used for both flows. -->
<div class="kanban-modal-overlay" id="kanbanBoardModal" hidden onclick="if(event.target===this)closeKanbanBoardModal()">
@@ -1582,5 +1590,21 @@
</div>
</div>
</div>
<!-- Conversation Outline Panel (#2124): floating jump-to-question panel -->
<button id="outlineToggleBtn" type="button" hidden
onclick="toggleOutlinePanel()"
title="Conversation outline"
aria-label="Toggle conversation outline"
aria-controls="outlinePanelWrapper">&#9776;</button>
<div id="outlinePanelWrapper" hidden
role="navigation" aria-label="Conversation outline">
<div class="outline-header">
<span data-i18n="outline_title">Outline</span>
<button class="outline-close-btn" type="button"
onclick="toggleOutlinePanel()"
aria-label="Close outline">&#215;</button>
</div>
<div id="outlinePanel"></div>
</div>
</body>
</html>

291
static/outline.js Normal file
View File

@@ -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 = '<p class="outline-empty">' + t('outline_empty') + '</p>';
_outlineSid = null;
return;
}
if (!S.messages) {
panel.innerHTML = '<p class="outline-empty">' + t('outline_loading') + '</p>';
_outlineSid = sid;
return;
}
_outlineSid = sid;
const entries = _buildEntries();
if (!entries.length) {
panel.innerHTML = '<p class="outline-empty">' + t('outline_empty') + '</p>';
return;
}
const items = entries.map(function(e) {
return '<button class="outline-entry" type="button" ' +
'onclick="window._outlineJump(' + e.rawIdx + ')">' +
'<span class="outline-entry-num">' + e.label + '</span>' +
'<span class="outline-entry-text">' + _escHtml(e.excerpt) + '</span>' +
'</button>';
});
panel.innerHTML = items.join('');
}
// Simple HTML-escape for entry text.
function _escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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 = '<p class="outline-empty">' + t('outline_loading') + '</p>';
_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-<panel>`
// class it toggles on <main>. 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);
})();

View File

@@ -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();

View File

@@ -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;}
}

View File

@@ -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()

View File

@@ -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'<script[^>]+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-<panel>`
# on <main>, 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