@@ -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]);