From aa5806bc4e338a4aa815deec2346722fc570ef50 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 10:47:41 -0400 Subject: [PATCH] Add a collapsible desktop conversation tray and layout settings --- .../web/von_interface/static/js/chatTab.js | 48 +++-- .../static/js/components/conversationTray.js | 186 +++++++++++++++++ .../von_interface/static/js/settingsPage.js | 33 ++- .../js/utils/conversationLayoutPreferences.js | 46 ++++ .../web/von_interface/static/styles.css | 196 ++++++++++++++---- .../web/von_interface/templates/chat_tab.html | 24 ++- .../von_interface/templates/settings_tab.html | 14 ++ tests/frontend/chatConversationLayout.test.js | 5 +- .../chatSessionAgentCreatedFilter.test.js | 2 + tests/frontend/conversationTray.test.js | 143 +++++++++++++ .../settingsConversationLayout.test.js | 53 +++++ 11 files changed, 675 insertions(+), 75 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/conversationTray.js create mode 100644 src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js create mode 100644 tests/frontend/conversationTray.test.js create mode 100644 tests/frontend/settingsConversationLayout.test.js diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 20a0e680d..38426b92e 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,3 +1,5 @@ +import { createConversationTray } from './components/conversationTray.js'; +import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; import { createVoiceConversation } from './voiceConversation.js'; import { getClientContext } from './clientContext.js'; import { createDictationController } from './dictation.js'; @@ -1247,10 +1249,10 @@ const LS_HIDDEN_CHAT_SESSIONS_PREFIX = 'von:hiddenChatSessionIds'; const LS_PINNED_CHAT_SESSIONS_PREFIX = 'von:pinnedChatSessionIds'; const LS_CHAT_SESSION_LAST_ACCESSED_PREFIX = 'von:chatSessionLastAccessed'; const LS_AGENT_CREATED_CHAT_SESSIONS_VISIBLE_PREFIX = 'von:showAgentCreatedChatSessions'; -const LS_CHAT_SESSION_TABS_LAYOUT_PREFIX = 'von:chatSessionTabsLayout'; +const LS_CHAT_SESSION_TABS_LAYOUT_PREFIX = CONVERSATION_LAYOUT_KEY; const CHAT_SESSION_TABS_LAYOUT_HORIZONTAL = 'horizontal'; const CHAT_SESSION_TABS_LAYOUT_VERTICAL = 'vertical'; -const CHAT_SESSION_TABS_NARROW_MEDIA_QUERY = '(max-width: 800px)'; +const CHAT_SESSION_TABS_NARROW_MEDIA_QUERY = CONVERSATION_NARROW_QUERY; const CHAT_SESSION_TABS_FETCH_LIMIT = 200; const AGENT_CREATED_CHAT_SESSION_ORIGIN_KINDS = new Set([ 'browser_test_fixture', @@ -1268,7 +1270,8 @@ let _pinnedSessionsUserKey = null; // Track current user's localStorage key let _agentCreatedSessionsVisibleUserKey = null; let chatSessionLastAccessedMap = new Map(); let _chatSessionLastAccessedUserKey = null; -let chatSessionTabsLayout = CHAT_SESSION_TABS_LAYOUT_HORIZONTAL; +let chatSessionTabsLayout = loadConversationLayoutPreferences().layout; +let conversationTrayController = null; let chatSessionTabsLayoutMediaQuery = null; let chatSessionTabsLayoutMediaListenerBound = false; let agentCreatedSessionVisibilityState = { @@ -1317,9 +1320,7 @@ function getChatSessionTabsLayoutStorageKey() { } function normaliseChatSessionTabsLayout(value) { - return value === CHAT_SESSION_TABS_LAYOUT_VERTICAL - ? CHAT_SESSION_TABS_LAYOUT_VERTICAL - : CHAT_SESSION_TABS_LAYOUT_HORIZONTAL; + return normaliseConversationLayout(value); } function isChatSessionTabsNarrowViewport() { @@ -1365,6 +1366,11 @@ function applyChatSessionTabsLayout(layout = chatSessionTabsLayout) { if (workspace) { workspace.dataset.tabsLayout = preferred; workspace.dataset.effectiveTabsLayout = effective; + if (conversationTrayController?.workspace !== workspace) { + conversationTrayController?.destroy(); + conversationTrayController = createConversationTray(workspace); + } + conversationTrayController.refresh(loadConversationLayoutPreferences()); } if (chatTab) { chatTab.dataset.conversationTabsLayout = preferred; @@ -1385,24 +1391,22 @@ function applyChatSessionTabsLayout(layout = chatSessionTabsLayout) { } function loadChatSessionTabsLayoutPreference() { - const storageKey = getChatSessionTabsLayoutStorageKey(); - let stored = null; - try { - stored = localStorage.getItem(storageKey); - } catch (e) { - console.warn('[chatTab] Failed to load conversation tab layout from localStorage:', e); - } - return applyChatSessionTabsLayout(normaliseChatSessionTabsLayout(stored)); + return applyChatSessionTabsLayout(loadConversationLayoutPreferences().layout); } function saveChatSessionTabsLayoutPreference() { - const storageKey = getChatSessionTabsLayoutStorageKey(); - try { - localStorage.setItem(storageKey, normaliseChatSessionTabsLayout(chatSessionTabsLayout)); - } catch (e) { - console.warn('[chatTab] Failed to save conversation tab layout to localStorage:', e); - } + saveConversationLayoutPreference(CONVERSATION_LAYOUT_KEY, chatSessionTabsLayout); +} + +// Settings may be embedded in an iframe. Its existing preference event reaches +// the parent; storage events cover separate same-origin windows. +function handleConversationLayoutPreferenceChange(event) { + const key = event.type === 'storage' ? event.key : event?.detail?.key; + if (key !== null && !CONVERSATION_LAYOUT_KEYS.includes(key)) return; + loadChatSessionTabsLayoutPreference(); } +window.addEventListener('von-preferences-changed', handleConversationLayoutPreferenceChange); +window.addEventListener('storage', handleConversationLayoutPreferenceChange); function setChatSessionTabsLayout(layout, options = {}) { const result = applyChatSessionTabsLayout(layout); @@ -27721,6 +27725,10 @@ function renderChatSessionTabs(sessions, activeSessionId) { preview.title = previewText; } + // The compact rail hides visible title/metadata, but retains their accessible name. + tab.dataset.trayInitial = Array.from(displayName || 'Conversation')[0].toLocaleUpperCase(); + tab.setAttribute('aria-label', tab.title); + tab.setAttribute('data-keep-title', 'true'); tab.appendChild(header); tab.appendChild(meta); if (previewText) { diff --git a/src/frontend/web/von_interface/static/js/components/conversationTray.js b/src/frontend/web/von_interface/static/js/components/conversationTray.js new file mode 100644 index 000000000..9fbb41118 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/conversationTray.js @@ -0,0 +1,186 @@ +import { + clampConversationTrayWidth, + CONVERSATION_TRAY_COLLAPSED_KEY, + CONVERSATION_TRAY_WIDTH_KEY, + saveConversationLayoutPreference +} from '../utils/conversationLayoutPreferences.js'; + +/** Adjust navigation chrome only: never remount or reload the conversation. */ +export function createConversationTray(workspace) { + const tray = workspace.querySelector('.chat-session-tabs-row'); + const navigation = workspace.querySelector('.chat-conversation-nav') || tray; + const toggle = workspace.querySelector('#conversationTrayToggle'); + const resize = workspace.querySelector('#conversationTrayResize'); + const tabs = workspace.querySelector('#chatSessionTabs'); + let preferences = { width: 260, collapsed: false, expandOnHover: true }; + let peek = false; + let enterTimer; + let leaveTimer; + let drag = null; + let presentation; + const scrollPositions = new Map(); + const cleanup = []; + const listen = (target, event, callback) => { + if (!target) return; + target.addEventListener(event, callback); + cleanup.push(() => target.removeEventListener(event, callback)); + }; + const isVertical = () => workspace.dataset.effectiveTabsLayout === 'vertical'; + const isMenuOpen = () => !!document.querySelector('.chat-session-menu.open'); + const maxWidth = () => Math.max(220, Math.min(400, (workspace.clientWidth || window.innerWidth) - 420)); + const effectiveWidth = () => Math.min(preferences.width, maxWidth()); + const clearTimers = () => { clearTimeout(enterTimer); clearTimeout(leaveTimer); }; + + function render() { + const collapsed = isVertical() && preferences.collapsed; + if (!collapsed) peek = false; + const nextPresentation = !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded'; + const presentationChanged = presentation !== nextPresentation; + if (tabs && presentationChanged && presentation) { + scrollPositions.set(presentation, { top: tabs.scrollTop, left: tabs.scrollLeft }); + } + presentation = nextPresentation; + workspace.dataset.trayCollapsed = String(collapsed); + workspace.dataset.trayPeek = String(collapsed && peek); + workspace.style.setProperty('--conversation-tray-width', `${effectiveWidth()}px`); + const position = scrollPositions.get(presentation); + if (tabs && presentationChanged && position) { + tabs.scrollTop = position.top; + tabs.scrollLeft = position.left; + } + if (toggle) { + const label = collapsed ? 'Keep conversation list open' : 'Collapse conversation list'; + toggle.setAttribute('aria-label', label); + toggle.title = label; + toggle.setAttribute('aria-expanded', String(!collapsed || peek)); + } + if (resize) { + resize.setAttribute('aria-valuenow', String(effectiveWidth())); + resize.setAttribute('aria-valuemax', String(maxWidth())); + } + } + + function setCollapsed(value) { + clearTimers(); + preferences.collapsed = value; + peek = false; + render(); + saveConversationLayoutPreference(CONVERSATION_TRAY_COLLAPSED_KEY, value); + } + + function closePeek() { + if (!peek || drag || isMenuOpen() + || (document.activeElement !== toggle && navigation?.contains(document.activeElement))) return; + peek = false; + render(); + } + + listen(toggle, 'click', () => setCollapsed(!preferences.collapsed)); + listen(navigation, 'pointerenter', (event) => { + clearTimers(); + if (event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return; + enterTimer = setTimeout(() => { peek = true; render(); }, 200); + }); + listen(navigation, 'pointerleave', () => { + clearTimers(); + leaveTimer = setTimeout(closePeek, 250); + }); + listen(navigation, 'focusin', (event) => { + clearTimers(); + // Focusing the collapse button itself must not immediately reopen it. + if (event.target !== toggle && preferences.collapsed && isVertical()) { + peek = true; + render(); + } + }); + listen(navigation, 'focusout', (event) => { + if (!navigation.contains(event.relatedTarget)) leaveTimer = setTimeout(closePeek, 0); + }); + listen(document, 'pointerdown', (event) => { + if (peek && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) { + clearTimers(); + peek = false; + render(); + } + }); + listen(document, 'keydown', (event) => { + if (event.key === 'Escape' && peek && !isMenuOpen()) { + clearTimers(); + peek = false; + render(); + toggle?.focus({ preventScroll: true }); + } + }); + + // Roving focus follows the actual layout. Enter/Space retain native activation. + listen(tabs, 'keydown', (event) => { + if (!event.target.matches?.('[role="tab"]')) return; + const backwards = isVertical() ? 'ArrowUp' : 'ArrowLeft'; + const forwards = isVertical() ? 'ArrowDown' : 'ArrowRight'; + if (![backwards, forwards, 'Home', 'End'].includes(event.key)) return; + const rows = Array.from(tabs.querySelectorAll('[role="tab"]')); + const index = rows.indexOf(event.target); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? rows.length - 1 + : (index + (event.key === forwards ? 1 : -1) + rows.length) % rows.length; + event.preventDefault(); + rows[next]?.focus({ preventScroll: true }); + rows[next]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }); + + function finishDrag(cancelled = false) { + if (!drag) return; + const start = drag; + drag = null; + if (resize?.hasPointerCapture?.(start.pointerId)) resize.releasePointerCapture(start.pointerId); + if (cancelled) preferences.width = start.savedWidth; + workspace.classList.remove('is-resizing-conversation-tray'); + render(); + if (!cancelled) saveConversationLayoutPreference(CONVERSATION_TRAY_WIDTH_KEY, preferences.width); + } + + listen(resize, 'pointerdown', (event) => { + if (event.button !== 0 || !isVertical()) return; + event.preventDefault(); + drag = { x: event.clientX, width: effectiveWidth(), savedWidth: preferences.width, pointerId: event.pointerId }; + resize.setPointerCapture?.(event.pointerId); + workspace.classList.add('is-resizing-conversation-tray'); + }); + listen(resize, 'pointermove', (event) => { + if (!drag) return; + preferences.width = Math.min(maxWidth(), clampConversationTrayWidth(drag.width + event.clientX - drag.x)); + render(); + }); + listen(resize, 'pointerup', () => finishDrag()); + listen(resize, 'pointercancel', () => finishDrag(true)); + listen(resize, 'lostpointercapture', () => finishDrag(true)); + listen(resize, 'keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + preferences.width = event.key === 'Home' ? 220 : event.key === 'End' ? maxWidth() + : Math.min(maxWidth(), clampConversationTrayWidth(effectiveWidth() + (event.key === 'ArrowRight' ? 10 : -10))); + render(); + saveConversationLayoutPreference(CONVERSATION_TRAY_WIDTH_KEY, preferences.width); + }); + listen(window, 'resize', render); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(render) : null; + observer?.observe(workspace); + + return { + workspace, + refresh(next) { + const wasCollapsed = preferences.collapsed; + preferences = { ...next }; + if (!isVertical() || wasCollapsed !== preferences.collapsed || !preferences.expandOnHover) { + clearTimers(); + peek = false; + } + render(); + }, + destroy() { + clearTimers(); + finishDrag(true); + observer?.disconnect(); + cleanup.forEach(fn => fn()); + } + }; +} diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 9d48ffe0b..58bbff5ed 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -1,3 +1,4 @@ +import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_TRAY_HOVER_KEY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js'; import { supportsAudioRecording } from './dictation.js'; import { ensureUniqueWindowSessionId, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js'; import { @@ -3766,14 +3767,22 @@ function getSpeechSettingsFromStorage() { }; } -function setupConversationHistorySettingsSection() { +export function setupConversationHistorySettingsSection() { const recentLimitInput = document.getElementById('settingsConversationRecentLimitInput'); const recentWindowDaysInput = document.getElementById('settingsConversationRecentWindowDaysInput'); - if (!recentLimitInput && !recentWindowDaysInput) { + const layoutSelect = document.getElementById('settingsConversationLayoutSelect'); + const hoverToggle = document.getElementById('settingsConversationTrayHoverToggle'); + if (!recentLimitInput && !recentWindowDaysInput && !layoutSelect && !hoverToggle) { return; } const refreshUiFromSettings = () => { + const layout = loadConversationLayoutPreferences(); + if (layoutSelect) layoutSelect.value = layout.layout; + if (hoverToggle) { + hoverToggle.checked = layout.expandOnHover; + hoverToggle.disabled = layout.layout === 'horizontal'; + } const settings = loadConversationHistorySettings((key) => safeLocalStorageGet(key)); if (recentLimitInput) { recentLimitInput.value = String(settings.recentLimit); @@ -3784,6 +3793,22 @@ function setupConversationHistorySettingsSection() { }; refreshUiFromSettings(); + layoutSelect?.addEventListener('change', () => { + const value = normaliseConversationLayout(layoutSelect.value); + saveConversationLayoutPreference(CONVERSATION_LAYOUT_KEY, value); + notifyPreferenceChanged(CONVERSATION_LAYOUT_KEY, value); + refreshUiFromSettings(); + }); + hoverToggle?.addEventListener('change', () => { + saveConversationLayoutPreference(CONVERSATION_TRAY_HOVER_KEY, hoverToggle.checked); + notifyPreferenceChanged(CONVERSATION_TRAY_HOVER_KEY, hoverToggle.checked); + }); + const onPreference = (event) => { + const key = event.type === 'storage' ? event.key : event?.detail?.key; + if (key === null || CONVERSATION_LAYOUT_KEYS.includes(key)) refreshUiFromSettings(); + }; + window.addEventListener('storage', onPreference); + window.addEventListener('von-preferences-changed', onPreference); if (recentLimitInput) { recentLimitInput.addEventListener('change', (event) => { @@ -5400,6 +5425,10 @@ document.getElementById('resetLocalPrefsButton')?.addEventListener('click', () = localStorage.removeItem(LS_CARTOUCHE_SHOW_ID); localStorage.removeItem(LS_CARTOUCHE_SHOW_KIND); localStorage.removeItem(LS_CARTOUCHE_KIND_AS_BG); + for (const key of CONVERSATION_LAYOUT_KEYS) { + localStorage.removeItem(key); + notifyPreferenceChanged(key, null); + } // Reset selects visually const orgSel = document.getElementById('currentOrganisationSelect'); if (orgSel) orgSel.selectedIndex = 0; const langSel = document.getElementById('preferredLanguageSelect'); if (langSel) langSel.value = 'en-NZ'; diff --git a/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js b/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js new file mode 100644 index 000000000..2d35dc8c6 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js @@ -0,0 +1,46 @@ +// Browser navigation preferences deliberately survive sign-in and organisation changes. +export const CONVERSATION_LAYOUT_KEY = 'von:chatSessionTabsLayout'; +export const CONVERSATION_TRAY_WIDTH_KEY = 'von:conversationTrayWidth'; +export const CONVERSATION_TRAY_COLLAPSED_KEY = 'von:conversationTrayCollapsed'; +export const CONVERSATION_TRAY_HOVER_KEY = 'von:conversationTrayExpandOnHover'; +export const CONVERSATION_LAYOUT_KEYS = [ + CONVERSATION_LAYOUT_KEY, CONVERSATION_TRAY_WIDTH_KEY, + CONVERSATION_TRAY_COLLAPSED_KEY, CONVERSATION_TRAY_HOVER_KEY +]; +export const CONVERSATION_NARROW_QUERY = '(max-width: 800px), (hover: none) and (pointer: coarse)'; +export const CONVERSATION_TRAY_MIN_WIDTH = 220; +export const CONVERSATION_TRAY_MAX_WIDTH = 400; + +export function normaliseConversationLayout(value) { + return value === 'horizontal' ? 'horizontal' : 'vertical'; +} + +export function clampConversationTrayWidth(value) { + const number = value == null || value === '' ? NaN : Number(value); + return Number.isFinite(number) + ? Math.max(CONVERSATION_TRAY_MIN_WIDTH, Math.min(CONVERSATION_TRAY_MAX_WIDTH, Math.round(number))) + : 260; +} + +const sessionFallback = new Map(); + +export function loadConversationLayoutPreferences(reader = (key) => + sessionFallback.has(key) ? sessionFallback.get(key) : localStorage.getItem(key)) { + const read = (key) => { try { return reader(key); } catch (_) { return null; } }; + return { + layout: normaliseConversationLayout(read(CONVERSATION_LAYOUT_KEY)), + width: clampConversationTrayWidth(read(CONVERSATION_TRAY_WIDTH_KEY)), + collapsed: read(CONVERSATION_TRAY_COLLAPSED_KEY) === 'true', + expandOnHover: read(CONVERSATION_TRAY_HOVER_KEY) !== 'false' + }; +} + +export function saveConversationLayoutPreference(key, value) { + try { + localStorage.setItem(key, String(value)); + sessionFallback.delete(key); + } catch (_) { + sessionFallback.set(key, String(value)); + } + window.dispatchEvent(new CustomEvent('von-preferences-changed', { detail: { key, value } })); +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 845c090a3..774e8266c 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -8657,101 +8657,207 @@ body[data-active-tab="settingsTab"] .tab-content-area { color: #1d4ed8; } +/* The same session list serves horizontal navigation, the docked tray and peek. */ +.chat-conversation-nav { display: contents; } +.conversation-tray-header, +.conversation-tray-resize { display: none; } + .conversation-workspace[data-effective-tabs-layout="vertical"] { - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + grid-template-columns: var(--conversation-tray-width, 260px) minmax(0, 1fr); align-items: start; - gap: clamp(16px, 2vw, 28px); + gap: 16px; + transition: grid-template-columns 180ms ease; } -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tabs-row { +.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-collapsed="true"] { + grid-template-columns: 48px minmax(0, 1fr); +} + +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-conversation-nav { + display: block; position: sticky; top: calc(var(--von-fixed-shell-height, 104px) + 8px); - z-index: 20; - align-self: start; + z-index: 40; /* Above the sticky composer (30), below app menus and dialogs. */ + /* Footer clearance must not push the sticky tray under the header at page end. */ + margin-bottom: calc(-1 * var(--von-fixed-footer-clearance, 64px)); min-width: 0; + height: calc(100dvh - var(--von-fixed-shell-height, 104px) - var(--von-fixed-footer-clearance, 64px) - 24px); max-height: calc(100vh - var(--von-fixed-shell-height, 104px) - var(--von-fixed-footer-clearance, 64px) - 24px); +} + +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tabs-row { + position: relative; + width: 100%; + height: 100%; + min-height: 0; + min-width: 0; margin: 0; - padding: 10px; + padding: 6px; box-sizing: border-box; flex-direction: column; align-items: stretch; gap: 7px; overflow: hidden; - border: 1px solid #e2e8f0; - border-radius: 12px; - background: rgba(248, 250, 252, 0.96); - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); - backdrop-filter: blur(8px); + border: 1px solid #dbe2da; + border-radius: 8px; + background: #f0f3ef; +} + +.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-peek="true"] .chat-session-tabs-row { + width: var(--conversation-tray-width, 260px); + box-shadow: 8px 8px 24px rgba(15, 23, 42, 0.16); + animation: conversationTrayPeek 160ms ease-out; +} + +@keyframes conversationTrayPeek { + from { clip-path: inset(0 calc(100% - 48px) 0 0); } + to { clip-path: inset(0 0 0 0); } +} + +.conversation-workspace[data-effective-tabs-layout="vertical"] .conversation-tray-header { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; + min-height: 32px; +} + +.conversation-tray-heading { font-size: 13px; font-weight: 600; white-space: nowrap; } +.conversation-tray-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: #475569; + cursor: pointer; + font-size: 20px; +} +.conversation-tray-toggle:hover { background: #dfe7dd; } +.conversation-tray-toggle:focus-visible, +.conversation-tray-resize:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; } +.conversation-tray-toggle span { transition: transform 180ms ease; } +[data-tray-collapsed="true"] .conversation-tray-toggle span { transform: rotate(180deg); } + +.conversation-workspace[data-effective-tabs-layout="vertical"] .conversation-tray-resize { + display: block; + position: absolute; + right: -4px; + top: 0; + bottom: 0; + width: 8px; + cursor: col-resize; + touch-action: none; + border-radius: 4px; } +.conversation-tray-resize:hover, +.conversation-tray-resize:focus-visible { background: rgba(37, 99, 235, 0.2); } +.conversation-workspace[data-tray-peek="true"] .conversation-tray-resize { + left: calc(var(--conversation-tray-width, 260px) - 4px); + right: auto; +} +.conversation-workspace.is-resizing-conversation-tray { transition: none; user-select: none; } .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tabs { flex: 1 1 auto; min-height: 0; width: 100%; - padding: 20px 3px 3px; + padding: 0 2px 3px; flex-direction: column; align-items: stretch; + gap: 4px; overflow-x: hidden; overflow-y: auto; + overscroll-behavior-y: contain; border: 0; scrollbar-width: thin; - scrollbar-color: #cbd5e1 transparent; + scrollbar-color: #bac7b8 transparent; } - .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-history-summary { - order: -1; - align-self: flex-end; - padding-right: 3px; + flex: 0 0 auto; + align-self: stretch; + padding: 6px 2px 0; + border-top: 1px solid #dbe2da; } - +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-history-controls { flex-wrap: wrap; } .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab { + flex: 0 0 auto; width: 100%; max-width: none; + min-height: 40px; box-sizing: border-box; - border-radius: 8px; - padding: 8px 9px; + border-radius: 6px; + padding: 5px 7px; text-align: left; white-space: normal; + gap: 1px; + font-size: 12px; } - -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-active, -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-open.is-active, -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-shared.is-active, -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-pinned.is-active, -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-agent-created.is-active { - box-shadow: inset 3px 0 0 #2563eb; +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-header { width: 100%; } +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-meta { padding-left: 22px; } +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-label { font-weight: 500; } +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab.is-active { + box-shadow: inset 3px 0 0 #168044; } - .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-new { position: sticky; top: 0; z-index: 3; - min-height: 36px; + min-height: 32px; + flex-direction: row; + justify-content: center; align-items: center; text-align: center; - background: #ffffff; -} - -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-group-start { - margin-top: 18px; + background: #f0f3ef; } - -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-group-badge { - top: -17px; - left: 5px; +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-new::after { + content: "New conversation"; + margin-left: 7px; + font-size: 12px; } - +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-group-start { margin-top: 20px; } +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-group-badge { top: -18px; left: 5px; } .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tabs-placeholder, .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tabs-placeholder-filtered { - width: 100%; - min-width: 0; - box-sizing: border-box; + width: 100%; min-width: 0; box-sizing: border-box; } - .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-label, .conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-meta, -.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-preview { - white-space: nowrap; +.conversation-workspace[data-effective-tabs-layout="vertical"] .chat-session-tab-preview { white-space: nowrap; } + +/* Only supplementary presentation disappears in the rail; each tab keeps its full accessible name. */ +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .conversation-tray-heading, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .conversation-tray-resize, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-history-summary, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-header, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-meta, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-preview, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-group-badge, +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-new::after { display: none; } +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab { + min-height: 32px; + padding: 5px 2px; + text-align: center; + align-items: center; +} +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab[data-session-id]::before { + content: attr(data-tray-initial); + font-size: 13px; + font-weight: 600; + line-height: 20px; +} +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab-group-start { margin-top: 10px; } +.conversation-workspace[data-tray-collapsed="true"][data-tray-peek="false"] .chat-session-tab.has-unread::after { + content: ""; position: absolute; right: 1px; top: 1px; width: 5px; height: 5px; border-radius: 50%; background: #2563eb; +} +@media (prefers-reduced-motion: reduce) { + .conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; } + .conversation-workspace[data-tray-peek="true"] .chat-session-tabs-row { animation: none; } } .chat-situation-toggle { diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index b92129e5f..d40e8cbee 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -1,14 +1,26 @@
-
-
-
-
-
+
+
+
+ + Conversations +
+
+
+
+
+
diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 3397dd80a..d6cfa54be 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -125,6 +125,20 @@

Conversations

in the conversation history tabs.

+
+ + +
+
+ +
+ Mobile and narrow windows keep the horizontal list. Layout, tray width and collapsed state are remembered in this browser.
diff --git a/tests/frontend/chatConversationLayout.test.js b/tests/frontend/chatConversationLayout.test.js index 9389a6866..959dc833e 100644 --- a/tests/frontend/chatConversationLayout.test.js +++ b/tests/frontend/chatConversationLayout.test.js @@ -47,13 +47,14 @@ describe('conversation single-scroll layout', () => { expect(jumpRule).toContain('position: fixed'); }); - test('uses a horizontal conversation workspace as the accessible default', () => { + test('declares the desktop tray preference with a horizontal pre-initialisation fallback', () => { const fragment = parseTemplate(template); const workspace = fragment.querySelector('#conversationWorkspace'); const sessionTabs = fragment.querySelector('#chatSessionTabs'); expect(workspace).toBeTruthy(); - expect(workspace.getAttribute('data-tabs-layout')).toBe('horizontal'); + expect(workspace.getAttribute('data-tabs-layout')).toBe('vertical'); + expect(workspace.getAttribute('data-effective-tabs-layout')).toBe('horizontal'); expect(sessionTabs).toBeTruthy(); expect(sessionTabs.getAttribute('role')).toBe('tablist'); expect(sessionTabs.getAttribute('aria-orientation')).toBe('horizontal'); diff --git a/tests/frontend/chatSessionAgentCreatedFilter.test.js b/tests/frontend/chatSessionAgentCreatedFilter.test.js index ebf7c8cfe..5bf95edf7 100644 --- a/tests/frontend/chatSessionAgentCreatedFilter.test.js +++ b/tests/frontend/chatSessionAgentCreatedFilter.test.js @@ -40,6 +40,8 @@ describe('chat session agent-created filtering', () => { `; localStorage.clear(); + // These legacy context-menu cases start from an explicit horizontal choice. + localStorage.setItem('von:chatSessionTabsLayout', 'horizontal'); global.fetch = jest.fn(async (url) => { const urlText = String(url); diff --git a/tests/frontend/conversationTray.test.js b/tests/frontend/conversationTray.test.js new file mode 100644 index 000000000..13b638537 --- /dev/null +++ b/tests/frontend/conversationTray.test.js @@ -0,0 +1,143 @@ +/** @jest-environment jsdom */ +import { createConversationTray } from '../../src/frontend/web/von_interface/static/js/components/conversationTray.js'; +import { + loadConversationLayoutPreferences, CONVERSATION_LAYOUT_KEY, + CONVERSATION_TRAY_WIDTH_KEY, CONVERSATION_TRAY_COLLAPSED_KEY, +} from '../../src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js'; + +const fs = require('fs'); +const path = require('path'); +const template = fs.readFileSync(path.resolve(__dirname, '../../src/frontend/web/von_interface/templates/chat_tab.html'), 'utf8'); +let controller, workspace, toggle, navigation, tabs, grip; +const preferences = (overrides = {}) => ({ ...loadConversationLayoutPreferences(), ...overrides }); +const pointer = (element, type, props = {}) => { + const event = new MouseEvent(type, { bubbles: true, button: 0, ...props }); + Object.defineProperty(event, 'pointerId', { value: 7 }); + element.dispatchEvent(event); +}; + +beforeEach(() => { + localStorage.clear(); + jest.useFakeTimers(); + document.body.innerHTML = template; + workspace = document.getElementById('conversationWorkspace'); + workspace.dataset.effectiveTabsLayout = 'vertical'; + Object.defineProperty(workspace, 'clientWidth', { configurable: true, value: 1100 }); + toggle = document.getElementById('conversationTrayToggle'); + navigation = workspace.querySelector('.chat-conversation-nav'); + tabs = document.getElementById('chatSessionTabs'); + tabs.innerHTML = ''; + grip = document.getElementById('conversationTrayResize'); + controller = createConversationTray(workspace); + controller.refresh(preferences()); +}); +afterEach(() => { + controller.destroy(); + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +test('defaults to left, honours explicit horizontal, and bounds persisted geometry', () => { + expect(loadConversationLayoutPreferences()).toEqual({ layout: 'vertical', width: 260, collapsed: false, expandOnHover: true }); + localStorage.setItem(CONVERSATION_LAYOUT_KEY, 'horizontal'); + localStorage.setItem(CONVERSATION_TRAY_WIDTH_KEY, '9999'); + expect(loadConversationLayoutPreferences()).toMatchObject({ layout: 'horizontal', width: 400 }); + expect(loadConversationLayoutPreferences(() => { throw new Error('Storage unavailable'); })).toMatchObject({ layout: 'vertical', width: 260 }); +}); + +test('collapse and expansion retain the transcript, draft, attachments and list scroll', () => { + const draft = document.getElementById('promptInput'); + draft.value = 'Keep this unsent draft'; + draft.setSelectionRange(5, 9); + const transcript = document.getElementById('scrollableField'); + transcript.innerHTML = '

Existing response

'; + const response = transcript.firstChild; + const attachment = document.createElement('img'); + draft.after(attachment); + tabs.scrollTop = 80; + toggle.click(); + expect(workspace.dataset.trayCollapsed).toBe('true'); + expect(localStorage.getItem(CONVERSATION_TRAY_COLLAPSED_KEY)).toBe('true'); + // A shorter rail can clamp the browser scroll position; expansion restores it. + tabs.scrollTop = 0; + toggle.click(); + expect(workspace.dataset.trayCollapsed).toBe('false'); + expect(transcript.firstChild).toBe(response); + expect(draft.value).toBe('Keep this unsent draft'); + expect(draft.selectionStart).toBe(5); + expect(attachment.isConnected).toBe(true); + expect(tabs.scrollTop).toBe(80); +}); + +test('hover peeks temporarily and leaving closes without changing the collapsed preference', () => { + toggle.click(); + toggle.focus(); + pointer(navigation, 'pointerenter'); + jest.advanceTimersByTime(201); + expect(workspace.dataset.trayPeek).toBe('true'); + pointer(navigation, 'pointerleave'); + jest.advanceTimersByTime(251); + expect(workspace.dataset.trayPeek).toBe('false'); + expect(localStorage.getItem(CONVERSATION_TRAY_COLLAPSED_KEY)).toBe('true'); +}); + +test('keyboard focus reveals titles even with hover disabled; Escape returns focus', () => { + controller.refresh(preferences({ collapsed: true, expandOnHover: false })); + pointer(navigation, 'pointerenter'); + jest.advanceTimersByTime(300); + expect(workspace.dataset.trayPeek).toBe('false'); + tabs.firstChild.focus(); + expect(workspace.dataset.trayPeek).toBe('true'); + tabs.firstChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(document.activeElement).toBe(tabs.lastChild); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(document.activeElement).toBe(toggle); + expect(workspace.dataset.trayPeek).toBe('false'); +}); + +test('temporary expansion keeps a conversation context menu usable', () => { + controller.refresh(preferences({ collapsed: true })); + pointer(navigation, 'pointerenter'); + jest.advanceTimersByTime(201); + const menu = document.createElement('div'); + menu.className = 'chat-session-menu open'; + document.body.append(menu); + pointer(navigation, 'pointerleave'); + jest.advanceTimersByTime(251); + expect(workspace.dataset.trayPeek).toBe('true'); + pointer(document.body, 'pointerdown'); + expect(workspace.dataset.trayPeek).toBe('false'); +}); + +test('resizing persists on release, clamps to available width, and cancellation restores width', () => { + pointer(grip, 'pointerdown', { clientX: 260 }); + pointer(grip, 'pointermove', { clientX: 320 }); + expect(workspace.style.getPropertyValue('--conversation-tray-width')).toBe('320px'); + expect(localStorage.getItem(CONVERSATION_TRAY_WIDTH_KEY)).toBeNull(); + pointer(grip, 'pointerup'); + expect(localStorage.getItem(CONVERSATION_TRAY_WIDTH_KEY)).toBe('320'); + pointer(grip, 'pointerdown', { clientX: 320 }); + pointer(grip, 'pointermove', { clientX: 380 }); + pointer(grip, 'pointercancel'); + expect(workspace.style.getPropertyValue('--conversation-tray-width')).toBe('320px'); + Object.defineProperty(workspace, 'clientWidth', { configurable: true, value: 700 }); + window.dispatchEvent(new Event('resize')); + expect(workspace.style.getPropertyValue('--conversation-tray-width')).toBe('280px'); + expect(localStorage.getItem(CONVERSATION_TRAY_WIDTH_KEY)).toBe('320'); +}); + +test('keyboard resize works and horizontal fallback does not erase desktop collapse', () => { + grip.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(localStorage.getItem(CONVERSATION_TRAY_WIDTH_KEY)).toBe('270'); + controller.refresh(preferences({ collapsed: true })); + workspace.dataset.effectiveTabsLayout = 'horizontal'; + controller.refresh(preferences({ collapsed: true })); + pointer(navigation, 'pointerenter'); + jest.advanceTimersByTime(300); + expect(workspace.dataset.trayCollapsed).toBe('false'); + expect(workspace.dataset.trayPeek).toBe('false'); + workspace.dataset.effectiveTabsLayout = 'vertical'; + controller.refresh(preferences({ collapsed: true })); + expect(workspace.dataset.trayCollapsed).toBe('true'); +}); diff --git a/tests/frontend/settingsConversationLayout.test.js b/tests/frontend/settingsConversationLayout.test.js new file mode 100644 index 000000000..39be871d4 --- /dev/null +++ b/tests/frontend/settingsConversationLayout.test.js @@ -0,0 +1,53 @@ +/** @jest-environment jsdom */ +import { setupConversationHistorySettingsSection } from '../../src/frontend/web/von_interface/static/js/settingsPage.js'; +import { + __testOnly_loadChatSessionTabsLayoutPreference as loadLayout, + __testOnly_setChatSessionTabsLayout as setLayout, +} from '../../src/frontend/web/von_interface/static/js/chatTab.js'; +import { CONVERSATION_LAYOUT_KEY, CONVERSATION_TRAY_HOVER_KEY } from '../../src/frontend/web/von_interface/static/js/utils/conversationLayoutPreferences.js'; +const fs = require('fs'); +const path = require('path'); +const settingsTemplate = fs.readFileSync(path.resolve(__dirname, '../../src/frontend/web/von_interface/templates/settings_tab.html'), 'utf8'); + +beforeEach(() => { + localStorage.clear(); + window.matchMedia = jest.fn(() => ({ matches: false })); + document.body.innerHTML = `
`; + const template = document.createElement('template'); + template.innerHTML = settingsTemplate; + document.body.append(template.content.querySelector('#conversation-history-settings')); + setupConversationHistorySettingsSection(); + loadLayout(); +}); +afterEach(() => jest.restoreAllMocks()); + +test('new desktop defaults left; Settings switches immediately without remounting the draft', () => { + const workspace = document.getElementById('conversationWorkspace'); + const draft = document.getElementById('draft'); + const select = document.getElementById('settingsConversationLayoutSelect'); + expect(workspace.dataset.effectiveTabsLayout).toBe('vertical'); + expect(select.value).toBe('vertical'); + select.value = 'horizontal'; + select.dispatchEvent(new Event('change')); + expect(workspace.dataset.effectiveTabsLayout).toBe('horizontal'); + expect(localStorage.getItem(CONVERSATION_LAYOUT_KEY)).toBe('horizontal'); + expect(document.getElementById('draft')).toBe(draft); + expect(draft.value).toBe('Unsent text'); + expect(document.getElementById('settingsConversationTrayHoverToggle').disabled).toBe(true); + setLayout('vertical'); + expect(select.value).toBe('vertical'); + const hover = document.getElementById('settingsConversationTrayHoverToggle'); + hover.checked = false; + hover.dispatchEvent(new Event('change')); + expect(localStorage.getItem(CONVERSATION_TRAY_HOVER_KEY)).toBe('false'); +}); + +test('narrow/coarse layout and cross-window changes keep the explicit preference truthful', () => { + window.matchMedia = jest.fn(() => ({ matches: true })); + expect(loadLayout()).toEqual({ preferred: 'vertical', effective: 'horizontal' }); + localStorage.setItem(CONVERSATION_LAYOUT_KEY, 'horizontal'); + window.dispatchEvent(new StorageEvent('storage', { key: CONVERSATION_LAYOUT_KEY, newValue: 'horizontal' })); + expect(document.getElementById('settingsConversationLayoutSelect').value).toBe('horizontal'); + window.matchMedia = jest.fn(() => ({ matches: false })); + expect(loadLayout()).toEqual({ preferred: 'horizontal', effective: 'horizontal' }); +});