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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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">☰</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">×</button>
|
||||
</div>
|
||||
<div id="outlinePanel"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
291
static/outline.js
Normal file
291
static/outline.js
Normal 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, '&')
|
||||
.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 = '<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);
|
||||
|
||||
})();
|
||||
@@ -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();
|
||||
|
||||
@@ -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;}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
117
tests/test_issue2124_outline_panel.py
Normal file
117
tests/test_issue2124_outline_panel.py
Normal 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
|
||||
Reference in New Issue
Block a user