Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 28 additions & 20 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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 = {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
};
}
33 changes: 31 additions & 2 deletions src/frontend/web/von_interface/static/js/settingsPage.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 } }));
}
Loading
Loading