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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
clampConversationTrayWidth,
CONVERSATION_TRAY_COLLAPSED_KEY,
CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY,
CONVERSATION_NARROW_QUERY,
CONVERSATION_TRAY_WIDTH_KEY,
saveConversationLayoutPreference
} from '../utils/conversationLayoutPreferences.js';
Expand All @@ -12,6 +14,8 @@ export function createConversationTray(workspace) {
const toggle = workspace.querySelector('#conversationTrayToggle');
const resize = workspace.querySelector('#conversationTrayResize');
const tabs = workspace.querySelector('#chatSessionTabs');
const context = workspace.querySelector('#conversationTrayContext');
const narrow = window.matchMedia?.(CONVERSATION_NARROW_QUERY);
let preferences = { width: 260, collapsed: false, expandOnHover: true };
let peek = false;
let enterTimer;
Expand All @@ -26,15 +30,32 @@ export function createConversationTray(workspace) {
cleanup.push(() => target.removeEventListener(event, callback));
};
const isVertical = () => workspace.dataset.effectiveTabsLayout === 'vertical';
const isMobile = () => !isVertical() && !!narrow?.matches;
const isCollapsed = () => isMobile() ? !!preferences.mobileCollapsed : isVertical() && preferences.collapsed;
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 updateContext(reveal = false) {
const active = tabs?.querySelector('[role="tab"][aria-selected="true"]');
const label = (active?.querySelector('.chat-session-tab-label') || active)?.textContent.trim();
const text = label ? `Current: ${label}` : 'Conversations';
if (context && context.textContent !== text) context.textContent = text;
if (reveal && active && isMobile() && !isCollapsed()) {
// Scroll only the list: scrollIntoView could displace the transcript.
const bounds = tabs.getBoundingClientRect();
const row = active.getBoundingClientRect();
if (row.left < bounds.left) tabs.scrollLeft += row.left - bounds.left;
else if (row.right > bounds.right) tabs.scrollLeft += Math.min(row.left - bounds.left, row.right - bounds.right);
}
}

function render() {
const collapsed = isVertical() && preferences.collapsed;
if (!collapsed) peek = false;
const nextPresentation = !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded';
const collapsed = isCollapsed();
workspace.dataset.trayMobile = String(isMobile());
if (!collapsed || !isVertical()) peek = false;
const nextPresentation = isMobile() && collapsed ? 'mobile-closed' : !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded';
const presentationChanged = presentation !== nextPresentation;
if (tabs && presentationChanged && presentation) {
scrollPositions.set(presentation, { top: tabs.scrollTop, left: tabs.scrollLeft });
Expand All @@ -58,14 +79,19 @@ export function createConversationTray(workspace) {
resize.setAttribute('aria-valuenow', String(effectiveWidth()));
resize.setAttribute('aria-valuemax', String(maxWidth()));
}
if (isMobile() && collapsed && document.activeElement !== toggle && navigation?.contains(document.activeElement)) {
toggle?.focus({ preventScroll: true });
}
updateContext(true);
}

function setCollapsed(value) {
clearTimers();
preferences.collapsed = value;
if (isMobile()) preferences.mobileCollapsed = value;
else preferences.collapsed = value;
peek = false;
render();
saveConversationLayoutPreference(CONVERSATION_TRAY_COLLAPSED_KEY, value);
saveConversationLayoutPreference(isMobile() ? CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY : CONVERSATION_TRAY_COLLAPSED_KEY, value);
}

function closePeek() {
Expand All @@ -75,7 +101,7 @@ export function createConversationTray(workspace) {
render();
}

listen(toggle, 'click', () => setCollapsed(!preferences.collapsed));
listen(toggle, 'click', () => setCollapsed(!isCollapsed()));
listen(navigation, 'pointerenter', (event) => {
clearTimers();
if (event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return;
Expand Down Expand Up @@ -104,8 +130,8 @@ export function createConversationTray(workspace) {
}
});
listen(document, 'keydown', (event) => {
if (event.key === 'Escape' && isVertical() && !isMenuOpen()
&& (peek || (!preferences.collapsed && navigation?.contains(event.target)))) {
if (event.key === 'Escape' && (isVertical() || isMobile()) && !isMenuOpen()
&& (peek || (!isCollapsed() && navigation?.contains(event.target)))) {
event.preventDefault();
clearTimers();
if (peek) {
Expand Down Expand Up @@ -170,6 +196,10 @@ export function createConversationTray(workspace) {
listen(window, 'resize', render);
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(render) : null;
observer?.observe(workspace);
// Session rendering replaces rows asynchronously, including catalogue selection.
const tabObserver = new MutationObserver(() => updateContext(true));
if (tabs) tabObserver.observe(tabs, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-selected'] });
listen(narrow, 'change', render);

return {
workspace,
Expand All @@ -186,6 +216,7 @@ export function createConversationTray(workspace) {
clearTimers();
finishDrag(true);
observer?.disconnect();
tabObserver.disconnect();
cleanup.forEach(fn => fn());
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
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_MOBILE_TRAY_COLLAPSED_KEY = 'von:conversationMobileTrayCollapsed';
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
CONVERSATION_TRAY_COLLAPSED_KEY, CONVERSATION_TRAY_HOVER_KEY,
CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY
];
export const CONVERSATION_NARROW_QUERY = '(max-width: 800px), (hover: none) and (pointer: coarse)';
export const CONVERSATION_TRAY_MIN_WIDTH = 220;
Expand All @@ -31,6 +33,7 @@ export function loadConversationLayoutPreferences(reader = (key) =>
layout: normaliseConversationLayout(read(CONVERSATION_LAYOUT_KEY)),
width: clampConversationTrayWidth(read(CONVERSATION_TRAY_WIDTH_KEY)),
collapsed: read(CONVERSATION_TRAY_COLLAPSED_KEY) === 'true',
mobileCollapsed: read(CONVERSATION_MOBILE_TRAY_COLLAPSED_KEY) === 'true',
expandOnHover: read(CONVERSATION_TRAY_HOVER_KEY) !== 'false'
};
}
Expand Down
35 changes: 35 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8699,6 +8699,7 @@ body[data-active-tab="settingsTab"] .tab-content-area {
/* The same session list serves horizontal navigation, the docked tray and peek. */
.chat-conversation-nav { display: contents; }
.conversation-tray-header,
.conversation-tray-context,
.conversation-tray-resize { display: none; }

.conversation-workspace[data-effective-tabs-layout="vertical"] {
Expand Down Expand Up @@ -8892,6 +8893,40 @@ body[data-active-tab="settingsTab"] .tab-content-area {
.conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; }
}

/* Keep current context above the mobile list without moving any conversation. */
.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs-row {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
gap: 4px;
}
.conversation-workspace[data-tray-mobile="true"] .conversation-tray-header {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.conversation-workspace[data-tray-mobile="true"] .conversation-tray-heading { display: none; }
.conversation-workspace[data-tray-mobile="true"] .conversation-tray-context {
display: block;
min-width: 0;
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 600;
}
.conversation-workspace[data-tray-mobile="true"] .conversation-tray-toggle {
flex-basis: 44px;
width: 44px;
height: 44px;
}
.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs {
width: 100%;
flex: 0 1 auto;
}
.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-tabs,
.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-history-summary,
.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .catalogue-filters { display: none; }

.chat-situation-toggle {
min-height: 32px;
}
Expand Down
1 change: 1 addition & 0 deletions src/frontend/web/von_interface/templates/chat_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
aria-label="Collapse conversation list" aria-expanded="true" aria-controls="chatSessionTabs"
title="Collapse conversation list" data-keep-title="true"><span aria-hidden="true">◧</span></button>
<span class="conversation-tray-heading">Conversations</span>
<span id="conversationTrayContext" class="conversation-tray-context" aria-live="polite"></span>
</div>
<div id="chatSessionTabs" class="chat-session-tabs" role="tablist" aria-label="Conversation sessions"
aria-orientation="horizontal"></div>
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/web/von_interface/templates/settings_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ <h2>Conversations</h2>
Expand collapsed conversation tray on hover
</label>
</div>
<small class="settings-note">Mobile and narrow windows keep the horizontal list. Layout, tray width and collapsed state are remembered in this browser.</small>
<small class="settings-note">Mobile and narrow windows start with the horizontal list open and keep the current conversation name above it. Collapse or reopen it using the list button. Layout, tray width and separate mobile/desktop collapsed states are remembered in this browser, across sign-ins; if storage is unavailable, choices last for this page only.</small>
<div class="speech-settings-row">
<label class="speech-settings-label" for="settingsConversationRecentLimitInput">Max recent
conversations (N)</label>
Expand Down
56 changes: 51 additions & 5 deletions tests/browser/responsiveShell.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,15 @@ const server = http.createServer((req, res) => {
});
async function noOverflow(page) {
const size = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth }));
assert(size.document <= size.viewport + 1, `global overflow: ${JSON.stringify(size)}`);
assert(size.document <= page.viewportSize().width + 1, `global overflow: ${JSON.stringify(size)}`);
}
(async () => {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ headless: true });
try {
for (const profile of [
{ name: 'pixel-8', width: 412, height: 915, touch: true },
{ name: 'small-phone', width: 320, height: 740, touch: true },
{ name: 'folded', width: 360, height: 740, touch: true },
{ name: 'tablet', width: 768, height: 1024, touch: true },
{ name: 'unfolded', width: 840, height: 900, touch: true },
Expand All @@ -59,7 +60,7 @@ async function noOverflow(page) {
await page.goto(`http://127.0.0.1:${server.address().port}`);
await noOverflow(page);
await expect(page.locator('#vonGoogleLoginButton')).toBeVisible();
await page.evaluate(async () => {
const preparePage = () => page.evaluate(async () => {
// Synthetic signed-in presentation; no credentials or identity bypass.
document.body.classList.remove('von-auth-pending');
document.querySelector('#vonAuthenticationGate').hidden = true;
Expand All @@ -71,22 +72,24 @@ async function noOverflow(page) {
<div class="footer-org-menu"><button class="footer-org-option">Personal</button><button class="footer-org-option">Research organisation</button></div></details></span></span>
<span class="footer-segment">Model: configured model</span>`;
document.querySelector('#chatSessionTabs').innerHTML = Array.from({ length: 12 }, (_, i) =>
`<button class="chat-session-tab" role="tab">Saved research conversation ${i + 1}</button>`).join('');
`<button class="chat-session-tab${i === 10 ? ' is-active' : ''}" role="tab" aria-selected="${i === 10}"><span class="chat-session-tab-label">Saved research conversation ${i + 1}</span></button>`).join('');
document.querySelector('#chatSessionHistoryControls').innerHTML = '<button>Older conversations</button><button>New conversation</button>';
document.querySelector('#scrollableField').innerHTML = Array.from({ length: 25 }, (_, i) =>
`<p>Turn ${i + 1}: A readable saved research conversation with provenance and next steps.</p>`).join('');
const workspace = document.querySelector('#conversationWorkspace');
const { createConversationTray } = await import('/static/js/components/conversationTray.js');
const { CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences } = await import('/static/js/utils/conversationLayoutPreferences.js');
const tray = createConversationTray(workspace);
const refresh = () => {
workspace.dataset.effectiveTabsLayout = innerWidth <= 900 ? 'horizontal' : 'vertical';
tray.refresh({ width: 260, collapsed: false, expandOnHover: true });
workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches ? 'horizontal' : 'vertical';
tray.refresh(loadConversationLayoutPreferences());
};
refresh();
window.addEventListener('resize', refresh);
const { setupDynamicLayout } = await import('/static/js/components/dynamicLayout.js');
setupDynamicLayout();
});
await preparePage();
await page.waitForTimeout(100);
await noOverflow(page);
const input = page.locator('#promptInput');
Expand Down Expand Up @@ -135,6 +138,49 @@ async function noOverflow(page) {
await noOverflow(page);
if (evidence) await page.screenshot({ path: path.join(evidence, 'desktop-tray-reopened.png') });
}
if (profile.touch) {
const toggle = page.locator('#conversationTrayToggle');
const context = page.locator('#conversationTrayContext');
const selected = page.locator('#chatSessionTabs [aria-selected="true"]');
await expect(context).toHaveText('Current: Saved research conversation 11');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const listBox = await page.locator('#chatSessionTabs').boundingBox();
const activeBox = await selected.boundingBox();
assert(activeBox.x >= listBox.x - 1 && activeBox.x + activeBox.width <= listBox.x + listBox.width + 1, 'active conversation revealed without reordering');
const longTitle = 'Research conversation with a deliberately long title about observations, provenance and the next collaborative steps';
await selected.locator('.chat-session-tab-label').evaluate((el, title) => { el.textContent = title; }, longTitle);
await expect(context).toHaveText(`Current: ${longTitle}`);
await noOverflow(page);
await selected.locator('.chat-session-tab-label').evaluate(el => { el.textContent = 'Saved research conversation 11'; });
await expect(context).toHaveText('Current: Saved research conversation 11');
const contextBox = await context.boundingBox();
assert(contextBox.y + contextBox.height <= (await selected.boundingBox()).y, 'current context above list');
const retained = await page.evaluateHandle(() => document.querySelector('#scrollableField').firstChild);
await selected.focus();
const scrollY = await page.evaluate(() => window.scrollY);
await page.keyboard.press('Escape');
await expect(toggle).toBeFocused();
await expect(toggle).toHaveAccessibleName('Open conversation list');
await expect(page.locator('#chatSessionTabs')).toBeHidden();
await expect(context).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('#chatSessionTabs')).toBeVisible();
assert.equal(await page.evaluate(() => window.scrollY), scrollY, 'reopening list leaves document scroll unchanged');
await page.keyboard.press('Space');
await expect(page.locator('#chatSessionTabs')).toBeHidden();
assert(await retained.evaluate(el => el === document.querySelector('#scrollableField').firstChild), 'mobile transcript retained');
await expect(input).toHaveValue('Draft survives resizing and folding.');
await noOverflow(page);
if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-collapsed.png`) });
await page.reload();
await preparePage();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(context).toHaveText('Current: Saved research conversation 11');
await toggle.click();
await expect(selected).toBeVisible();
await input.fill('Draft survives resizing and folding.');
if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-reopened.png`) });
}
if (trayOnly) {
console.log(JSON.stringify({ profile: profile.name, passed: true, scope: 'conversation tray', source: 'synthetic production-template fixture' }));
await page.close();
Expand Down
2 changes: 1 addition & 1 deletion tests/frontend/chatConversationLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('conversation single-scroll layout', () => {
expect(composerRule).toContain('position: sticky');
expect(template).toContain('class="chat-composer"');
expect(template).toContain('<details class="chat-composer-more-actions">');
expect(template).toContain('<textarea id="promptInput" rows="1"');
expect(parseTemplate(template).querySelector('#promptInput').getAttribute('rows')).toBe('1');
});

test('positions the latest-message control against the viewport', () => {
Expand Down
Loading
Loading