From d6fefe74243f7f88d2b955d3192b4af723a4656f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:50:11 +0200 Subject: [PATCH 1/4] fix: Hide the Scrollbar on the Web Search Source Rails The three horizontal rails carried a class Tailwind 3 never generated, so they still draw a scrollbar. They now use the app's hide-scrollbar helper; the links inside stay focusable, so the rails stay reachable by keyboard and touch. --- client/src/components/Web/Sources.tsx | 6 +++--- eslint-suppressions.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/components/Web/Sources.tsx b/client/src/components/Web/Sources.tsx index fa0ad75e996..73f429fb289 100644 --- a/client/src/components/Web/Sources.tsx +++ b/client/src/components/Web/Sources.tsx @@ -408,7 +408,7 @@ const SourcesGroup = React.memo(function SourcesGroup({ }, [sources, limit]); return ( -
+
{visibleSources.map((source, i) => (
@@ -497,7 +497,7 @@ function FilesGroup({ files, messageId, conversationId, limit = 3 }: FilesGroupP const hasMoreFiles = remainingFiles.length > 0; return ( -
+
{visibleFiles.map((file, i) => (
@@ -726,7 +726,7 @@ function SourcesComponent({ messageId, conversationId }: SourcesProps = {}) { tabs={tabs} containerClassName="flex min-w-full mb-4" tabListClassName="flex items-center mb-2 border-b border-border-light overflow-x-auto" - tabPanelClassName="w-full overflow-x-auto md:mx-0 md:px-0" + tabPanelClassName="hide-scrollbar w-full overflow-x-auto md:mx-0 md:px-0" tabClassName="flex items-center whitespace-nowrap text-xs font-medium px-1 pt-2 pb-1 border-b-2 border-transparent focus:ring-2 focus:ring-text-primary focus:ring-offset-2" />
diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b9c20f336d2..e162980570d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1400,7 +1400,7 @@ "count": 1 }, "shadcn/no-restyle": { - "count": 44 + "count": 45 } }, "client/src/components/ui/AdminSettingsDialog.tsx": { From 54f85744d73c9980e21552df7948c6f20bdf4bac Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:59:09 +0200 Subject: [PATCH 2/4] fix: Keep the Source Tab Panel Off the Suppression Backlog --- client/src/components/Web/Sources.tsx | 2 +- eslint-suppressions.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/Web/Sources.tsx b/client/src/components/Web/Sources.tsx index 73f429fb289..8d0a1c9d741 100644 --- a/client/src/components/Web/Sources.tsx +++ b/client/src/components/Web/Sources.tsx @@ -726,7 +726,7 @@ function SourcesComponent({ messageId, conversationId }: SourcesProps = {}) { tabs={tabs} containerClassName="flex min-w-full mb-4" tabListClassName="flex items-center mb-2 border-b border-border-light overflow-x-auto" - tabPanelClassName="hide-scrollbar w-full overflow-x-auto md:mx-0 md:px-0" + tabPanelClassName="w-full overflow-x-auto md:mx-0 md:px-0" tabClassName="flex items-center whitespace-nowrap text-xs font-medium px-1 pt-2 pb-1 border-b-2 border-transparent focus:ring-2 focus:ring-text-primary focus:ring-offset-2" />
diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e162980570d..b9c20f336d2 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1400,7 +1400,7 @@ "count": 1 }, "shadcn/no-restyle": { - "count": 45 + "count": 44 } }, "client/src/components/ui/AdminSettingsDialog.tsx": { From e12c77cdd69c18fa51d9cfa9611f5e82901f62ef Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:33 +0200 Subject: [PATCH 3/4] test: Cover the Web Search Source Rails Scrollbar and Keyboard Reach --- e2e/specs/mock/db.ts | 2 + e2e/specs/mock/scenarios/source-rails.spec.ts | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 e2e/specs/mock/scenarios/source-rails.spec.ts diff --git a/e2e/specs/mock/db.ts b/e2e/specs/mock/db.ts index 4a0492eb7a0..be9d8b6ec46 100644 --- a/e2e/specs/mock/db.ts +++ b/e2e/specs/mock/db.ts @@ -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[]; + /** Tool attachments on the turn, such as the web search results behind its sources. */ + attachments?: Record[]; } /** diff --git a/e2e/specs/mock/scenarios/source-rails.spec.ts b/e2e/specs/mock/scenarios/source-rails.spec.ts new file mode 100644 index 00000000000..5df0f1317f9 --- /dev/null +++ b/e2e/specs/mock/scenarios/source-rails.spec.ts @@ -0,0 +1,109 @@ +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 { + 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) => ({ + 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]); + } + }); +}); From 14bb2ee8c3bf60b6f83a05db6d0051a0da6c986f Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:11:08 +0200 Subject: [PATCH 4/4] test: Force the Source Rail to Overflow Before Reading Its Scrollbar --- e2e/specs/mock/scenarios/source-rails.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/e2e/specs/mock/scenarios/source-rails.spec.ts b/e2e/specs/mock/scenarios/source-rails.spec.ts index 5df0f1317f9..84fbe22bce0 100644 --- a/e2e/specs/mock/scenarios/source-rails.spec.ts +++ b/e2e/specs/mock/scenarios/source-rails.spec.ts @@ -72,11 +72,14 @@ test.describe('web search source rails', () => { .getByRole('link') .first() .locator('xpath=ancestor::div[contains(@class, "overflow-x-auto")][1]'); - const metrics = await rail.evaluate((node) => ({ - overflowX: getComputedStyle(node).overflowX, - scrollbarWidth: getComputedStyle(node).scrollbarWidth, - overflowing: node.scrollWidth > node.clientWidth, - })); + 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]);