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
30 changes: 10 additions & 20 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -1361,24 +1361,15 @@ function isChatSessionTabsNarrowViewport() {
}

function getEffectiveChatSessionTabsLayout(preferredLayout = chatSessionTabsLayout) {
const preferred = normaliseChatSessionTabsLayout(preferredLayout);
if (preferred !== CHAT_SESSION_TABS_LAYOUT_VERTICAL) {
return CHAT_SESSION_TABS_LAYOUT_HORIZONTAL;
}
if (isChatSessionTabsNarrowViewport()) {
return CHAT_SESSION_TABS_LAYOUT_HORIZONTAL;
}
return CHAT_SESSION_TABS_LAYOUT_VERTICAL;
// Desktop layout is a preference; narrow/touch navigation always uses the tray.
return isChatSessionTabsNarrowViewport()
? CHAT_SESSION_TABS_LAYOUT_VERTICAL
: normaliseChatSessionTabsLayout(preferredLayout);
}

function getChatSessionTabsLayoutMenuLabel() {
if (chatSessionTabsLayout === CHAT_SESSION_TABS_LAYOUT_VERTICAL) {
return isChatSessionTabsNarrowViewport()
? 'Use conversation tabs across top on wider screens'
: 'Move conversation tabs to top';
}
return isChatSessionTabsNarrowViewport()
? 'Use conversation tabs on left on wider screens'
return chatSessionTabsLayout === CHAT_SESSION_TABS_LAYOUT_VERTICAL
? 'Move conversation tabs to top'
: 'Move conversation tabs to left';
}

Expand Down Expand Up @@ -1443,9 +1434,8 @@ function setChatSessionTabsLayout(layout, options = {}) {
}
if (options.announce === true) {
let message;
if (result.preferred === CHAT_SESSION_TABS_LAYOUT_VERTICAL
&& result.effective === CHAT_SESSION_TABS_LAYOUT_HORIZONTAL) {
message = 'Left conversation tabs saved for wider screens.';
if (isChatSessionTabsNarrowViewport()) {
message = 'Desktop conversation layout saved.';
} else if (result.preferred === CHAT_SESSION_TABS_LAYOUT_VERTICAL) {
message = 'Conversation tabs moved to the left.';
} else {
Expand Down Expand Up @@ -27756,12 +27746,12 @@ function buildNewChatContextMenuItems() {
});
}
},
{
...(!isChatSessionTabsNarrowViewport() ? [{
label: getChatSessionTabsLayoutMenuLabel(),
onClick: () => {
toggleChatSessionTabsLayout({ announce: true });
}
}
}] : [])
];

const agentTotalCount = agentCreatedSessionVisibilityState.totalCount || 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function createConversationTray(workspace) {
cleanup.push(() => target.removeEventListener(event, callback));
};
const isVertical = () => workspace.dataset.effectiveTabsLayout === 'vertical';
const isMobile = () => !isVertical() && !!narrow?.matches;
const isMobile = () => !!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));
Expand All @@ -46,16 +46,16 @@ export function createConversationTray(workspace) {
// 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);
if (row.top < bounds.top) tabs.scrollTop += row.top - bounds.top;
else if (row.bottom > bounds.bottom) tabs.scrollTop += Math.min(row.top - bounds.top, row.bottom - bounds.bottom);
}
}

function render() {
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';
if (!collapsed || !isVertical() || isMobile()) peek = false;
const nextPresentation = isMobile() ? (collapsed ? 'mobile-closed' : 'mobile-open') : !isVertical() ? 'horizontal' : collapsed && !peek ? 'rail' : 'expanded';
const presentationChanged = presentation !== nextPresentation;
if (tabs && presentationChanged && presentation) {
scrollPositions.set(presentation, { top: tabs.scrollTop, left: tabs.scrollLeft });
Expand Down Expand Up @@ -104,7 +104,7 @@ export function createConversationTray(workspace) {
listen(toggle, 'click', () => setCollapsed(!isCollapsed()));
listen(navigation, 'pointerenter', (event) => {
clearTimers();
if (event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return;
if (isMobile() || event.pointerType === 'touch' || !preferences.expandOnHover || !preferences.collapsed || !isVertical()) return;
enterTimer = setTimeout(() => { peek = true; render(); }, 200);
});
listen(navigation, 'pointerleave', () => {
Expand All @@ -114,15 +114,23 @@ export function createConversationTray(workspace) {
listen(navigation, 'focusin', (event) => {
clearTimers();
// Focusing the collapse button itself must not immediately reopen it.
if (event.target !== toggle && preferences.collapsed && isVertical()) {
if (!isMobile() && event.target !== toggle && preferences.collapsed && isVertical()) {
peek = true;
render();
}
});
listen(navigation, 'focusout', (event) => {
if (!navigation.contains(event.relatedTarget)) leaveTimer = setTimeout(closePeek, 0);
});
listen(document, 'focusin', (event) => {
if (isMobile() && !isCollapsed() && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) {
setCollapsed(true);
}
});
listen(document, 'pointerdown', (event) => {
if (isMobile() && !isCollapsed() && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) {
setCollapsed(true);
}
if (peek && !navigation?.contains(event.target) && !event.target.closest?.('.chat-session-menu')) {
clearTimers();
peek = false;
Expand All @@ -144,6 +152,10 @@ export function createConversationTray(workspace) {
}
});

listen(tabs, 'click', (event) => {
if (isMobile() && event.target.closest?.('[role="tab"]')) setCollapsed(true);
});

// Roving focus follows the actual layout. Enter/Space retain native activation.
listen(tabs, 'keydown', (event) => {
if (!event.target.matches?.('[role="tab"]')) return;
Expand Down Expand Up @@ -171,7 +183,7 @@ export function createConversationTray(workspace) {
}

listen(resize, 'pointerdown', (event) => {
if (event.button !== 0 || !isVertical()) return;
if (event.button !== 0 || !isVertical() || isMobile()) return;
event.preventDefault();
drag = { x: event.clientX, width: effectiveWidth(), savedWidth: preferences.width, pointerId: event.pointerId };
resize.setPointerCapture?.(event.pointerId);
Expand All @@ -186,6 +198,7 @@ export function createConversationTray(workspace) {
listen(resize, 'pointercancel', () => finishDrag(true));
listen(resize, 'lostpointercapture', () => finishDrag(true));
listen(resize, 'keydown', (event) => {
if (isMobile()) return;
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
preferences.width = event.key === 'Home' ? 220 : event.key === 'End' ? maxWidth()
Expand Down
35 changes: 33 additions & 2 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9021,6 +9021,34 @@ body[data-active-tab="settingsTab"] .tab-content-area {
.conversation-workspace[data-effective-tabs-layout="vertical"], .conversation-tray-toggle span { transition: none; }
}

/* Mobile keeps chat full-width and opens the shared left tray over it. */
.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-mobile="true"][data-tray-collapsed] {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.conversation-workspace[data-tray-mobile="true"] .chat-conversation-nav {
height: 58px;
max-height: none;
margin-bottom: 0;
}
.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs-row {
position: absolute;
top: 0;
left: 0;
width: min(320px, 100%);
height: max(120px, calc(100dvh - var(--von-fixed-shell-height, 104px) - var(--von-fixed-footer-clearance, 64px) - 24px));
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.16);
}
.conversation-workspace[data-tray-mobile="true"][data-tray-collapsed="true"] .chat-session-tabs-row {
width: 100%;
height: 58px;
box-shadow: none;
}
.conversation-workspace[data-effective-tabs-layout="vertical"][data-tray-mobile="true"] .conversation-tray-resize { display: none; }
@media (max-width: 800px), (hover: none) and (pointer: coarse) {
.conversation-desktop-setting { display: 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;
Expand All @@ -9038,7 +9066,9 @@ body[data-active-tab="settingsTab"] .tab-content-area {
.conversation-workspace[data-tray-mobile="true"] .conversation-tray-context {
display: block;
min-width: 0;
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 600;
}
Expand All @@ -9049,8 +9079,9 @@ body[data-active-tab="settingsTab"] .tab-content-area {
}
.conversation-workspace[data-tray-mobile="true"] .chat-session-tabs {
width: 100%;
flex: 0 1 auto;
flex: 1 1 auto;
}
.conversation-workspace[data-tray-mobile="true"] .chat-session-tab { min-height: 44px; }
.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; }
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/web/von_interface/templates/settings_tab.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,20 @@ <h2>Conversations</h2>
in the conversation history tabs.</p>

<div class="speech-settings-panel">
<div class="speech-settings-row">
<div class="speech-settings-row conversation-desktop-setting">
<label class="speech-settings-label" for="settingsConversationLayoutSelect">Conversation list on desktop</label>
<select id="settingsConversationLayoutSelect" class="speech-settings-select">
<option value="vertical">Left tray (default)</option>
<option value="horizontal">Horizontal across top</option>
</select>
</div>
<div class="speech-settings-row">
<div class="speech-settings-row conversation-desktop-setting">
<label for="settingsConversationTrayHoverToggle">
<input id="settingsConversationTrayHoverToggle" type="checkbox" />
Expand collapsed conversation tray on hover
</label>
</div>
<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>
<small class="settings-note">Mobile and narrow windows use the left conversation tray, initially open, with the current conversation name above it. Collapse or reopen it using the list button; choosing a conversation closes the tray. 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
40 changes: 37 additions & 3 deletions tests/browser/responsiveShell.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async function noOverflow(page) {
{ name: 'desktop', width: 1440, height: 900 }
].filter(profile => !baseline || profile.name === 'desktop')) {
const page = await browser.newPage({ viewport: profile, hasTouch: !!profile.touch, isMobile: !!profile.touch });
page.setDefaultTimeout(20000);
await page.goto(`http://127.0.0.1:${server.address().port}`);
await noOverflow(page);
await expect(page.locator('#vonGoogleLoginButton')).toBeVisible();
Expand All @@ -81,7 +82,8 @@ async function noOverflow(page) {
const { CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences } = await import('/static/js/utils/conversationLayoutPreferences.js');
const tray = createConversationTray(workspace);
const refresh = () => {
workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches ? 'horizontal' : 'vertical';
workspace.dataset.effectiveTabsLayout = matchMedia(CONVERSATION_NARROW_QUERY).matches
? 'vertical' : loadConversationLayoutPreferences().layout;
tray.refresh(loadConversationLayoutPreferences());
};
refresh();
Expand All @@ -92,6 +94,10 @@ async function noOverflow(page) {
await preparePage();
await page.waitForTimeout(100);
await noOverflow(page);
if (profile.touch) {
await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical');
await expect(page.locator('#conversationTrayToggle')).toHaveAttribute('aria-expanded', 'true');
}
const input = page.locator('#promptInput');
await input.fill('Draft survives resizing and folding.');
await page.locator('#sendButton').click({ trial: true });
Expand Down Expand Up @@ -143,10 +149,13 @@ async function noOverflow(page) {
const context = page.locator('#conversationTrayContext');
const selected = page.locator('#chatSessionTabs [aria-selected="true"]');
await expect(context).toHaveText('Current: Saved research conversation 11');
await toggle.click(); // Outside presses above dismiss the mobile panel.
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical');
await expect(page.locator('#conversationTrayResize')).toBeHidden();
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');
assert(activeBox.y >= listBox.y - 1 && activeBox.y + activeBox.height <= listBox.y + listBox.height + 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}`);
Expand All @@ -172,16 +181,37 @@ async function noOverflow(page) {
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.evaluate(() => localStorage.setItem('von:chatSessionTabsLayout', 'horizontal'));
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.');
assert.equal(await page.evaluate(() => localStorage.getItem('von:chatSessionTabsLayout')), 'horizontal');
if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-tray-reopened.png`) });
await selected.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await input.fill('Draft survives resizing and folding.');
for (const viewport of [{ width: 915, height: 412 }, { width: profile.width, height: profile.height }]) {
await page.setViewportSize(viewport);
await expect(input).toHaveValue('Draft survives resizing and folding.');
await expect(selected).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#conversationWorkspace')).toHaveAttribute('data-effective-tabs-layout', 'vertical');
await toggle.click();
await expect(selected).toBeVisible();
await toggle.click();
await expect(page.locator('#chatSessionTabs')).toBeHidden();
await noOverflow(page);
}
}
if (trayOnly) {
await page.goto(`http://127.0.0.1:${server.address().port}/settings`);
for (const id of ['settingsConversationLayoutSelect', 'settingsConversationTrayHoverToggle']) {
if (profile.touch) await expect(page.locator(`#${id}`)).toBeHidden();
else await expect(page.locator(`#${id}`)).toBeVisible();
}
if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-settings.png`) });
console.log(JSON.stringify({ profile: profile.name, passed: true, scope: 'conversation tray', source: 'synthetic production-template fixture' }));
await page.close();
continue;
Expand Down Expand Up @@ -215,6 +245,10 @@ async function noOverflow(page) {
await page.goto(`http://127.0.0.1:${server.address().port}/settings`);
await noOverflow(page);
await expect(page.locator('#preferredLanguageSelect')).toBeVisible();
for (const id of ['settingsConversationLayoutSelect', 'settingsConversationTrayHoverToggle']) {
if (profile.touch) await expect(page.locator(`#${id}`)).toBeHidden();
else await expect(page.locator(`#${id}`)).toBeVisible();
}
if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}-settings.png`) });
console.log(JSON.stringify({ profile: profile.name, passed: true, source: 'synthetic production-template fixture' }));
await page.close();
Expand Down
Loading
Loading