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
4 changes: 2 additions & 2 deletions client/src/components/Web/Sources.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ const SourcesGroup = React.memo(function SourcesGroup({
}, [sources, limit]);

return (
<div className="grid w-full grid-cols-4 gap-2 overflow-x-auto">
<div className="hide-scrollbar grid w-full grid-cols-4 gap-2 overflow-x-auto">
<OGDialog>
{visibleSources.map((source, i) => (
<div key={`source-${i}`} className="w-full min-w-[7.5rem]">
Expand Down Expand Up @@ -497,7 +497,7 @@ function FilesGroup({ files, messageId, conversationId, limit = 3 }: FilesGroupP
const hasMoreFiles = remainingFiles.length > 0;

return (
<div className="grid w-full grid-cols-4 gap-2 overflow-x-auto">
<div className="hide-scrollbar grid w-full grid-cols-4 gap-2 overflow-x-auto">
<OGDialog>
{visibleFiles.map((file, i) => (
<div key={`file-${i}`} className="w-full min-w-[7.5rem]">
Expand Down
2 changes: 2 additions & 0 deletions e2e/specs/mock/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export interface SeedMessage {
model?: string;
/** Attachments on the turn, as the client renders them (an image opens the lightbox). */
files?: Record<string, unknown>[];
/** Tool attachments on the turn, such as the web search results behind its sources. */
attachments?: Record<string, unknown>[];
}

/**
Expand Down
112 changes: 112 additions & 0 deletions e2e/specs/mock/scenarios/source-rails.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { randomUUID } from 'crypto';
import { expect, test } from '@playwright/test';
import { deleteConversations, seedConversations, seedMessages } from '../db';
import { getE2EUser } from '../../../setup/user';

/**
* The web search source rails scroll sideways when the sources outgrow the row. They hide
* their scrollbar the way the other carousels do, and their links stay reachable by keyboard.
*/

const SOURCE_COUNT = 8;
const REGION_NAME = 'Search results and sources';

test.use({ viewport: { width: 360, height: 800 } });

/** A two-lane assistant turn, the shape that renders the sources region, with web search results. */
async function seedSourcesChat(title: string): Promise<string> {
const conversationId = randomUUID();
const { email } = getE2EUser();
await seedConversations(email, [{ conversationId, title, updatedAt: new Date() }]);
const userMessageId = randomUUID();
const assistantMessageId = randomUUID();
const organic = Array.from({ length: SOURCE_COUNT }, (_, index) => ({
title: `Source ${index + 1}`,
link: `https://source-${index + 1}.e2e.invalid/page`,
snippet: `Snippet ${index + 1}`,
attribution: `source-${index + 1}.e2e.invalid`,
}));
await seedMessages(email, conversationId, [
{
messageId: userMessageId,
parentMessageId: '00000000-0000-0000-0000-000000000000',
text: 'Search the web.',
isCreatedByUser: true,
sender: 'User',
},
{
messageId: assistantMessageId,
parentMessageId: userMessageId,
text: 'Two lanes answered.',
isCreatedByUser: false,
sender: 'Mock Provider A',
content: [
{ type: 'text', text: 'Lane one answered.', groupId: 1, agentId: 'agent_lane_one' },
{ type: 'text', text: 'Lane two answered.', groupId: 1, agentId: 'agent_lane_two' },
],
attachments: [
{
type: 'web_search',
messageId: assistantMessageId,
toolCallId: randomUUID(),
conversationId,
web_search: { turn: 0, organic, topStories: [], images: [], references: [] },
},
],
},
]);
return conversationId;
}

test.describe('web search source rails', () => {
test('the source rail scrolls sideways without drawing a scrollbar @scenario:source-rails-hide-scrollbar', async ({
page,
}) => {
test.setTimeout(90000);
const conversationId = await seedSourcesChat('Source rails scrollbar');
try {
await page.goto(`/c/${conversationId}`);
const region = page.getByRole('region', { name: REGION_NAME });
await expect(region).toBeVisible({ timeout: 20000 });
const rail = region
.getByRole('link')
.first()
.locator('xpath=ancestor::div[contains(@class, "overflow-x-auto")][1]');
const metrics = await rail.evaluate((node) => {
node.style.width = '200px';
return {
overflowX: getComputedStyle(node).overflowX,
scrollbarWidth: getComputedStyle(node).scrollbarWidth,
overflowing: node.scrollWidth > node.clientWidth,
};
});
expect(metrics).toEqual({ overflowX: 'auto', scrollbarWidth: 'none', overflowing: true });
} finally {
await deleteConversations([conversationId]);
}
});

test('links in an overflowing source rail stay reachable by keyboard @scenario:source-rails-stay-keyboard-reachable', async ({
page,
}) => {
test.setTimeout(90000);
const conversationId = await seedSourcesChat('Source rails keyboard');
try {
await page.goto(`/c/${conversationId}`);
const region = page.getByRole('region', { name: REGION_NAME });
await expect(region).toBeVisible({ timeout: 20000 });
const last = region.getByRole('link', { name: /Source 3/ }).first();
await region.getByRole('link').first().focus();
for (let step = 0; step < 12; step++) {
if (await last.evaluate((node) => node === document.activeElement)) {
break;
}
await page.keyboard.press('Tab');
}
await expect(last).toBeFocused();
await expect(last).toBeInViewport();
} finally {
await deleteConversations([conversationId]);
}
});
});
Loading