From 70c0ffcfb5c9b6dcccf927dda2db262565825f90 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 8 Jun 2026 19:56:19 +0800 Subject: [PATCH] fix(site/src): horizontally scroll wide code blocks in agent chat (#26016) closes CODAGT-467 ## Problem In the agent chat timeline, wide tool-preview code blocks did not wrap **and** had no usable horizontal scroll. Long lines were clipped and unreachable for mouse users. Root cause: file/code/JSON previews (`read_file`, generic & MCP tool input/output) and markdown fenced code blocks render through `@pierre/diffs`, whose `[data-code]` grid grows to its content width (`align-self: flex-start`). The wrapping `ScrollArea` only rendered a **vertical** scrollbar, so although the viewport was horizontally scrollable, there was no scrollbar affordance and the overflow was clipped. ## Fix Render a horizontal scrollbar on these previews via `ScrollArea orientation="both"`, exposing the already-scrollable viewport with a visible 6px bar (consistent with the existing hover scrollbars). For markdown, let `[data-code]` size to its content so the outer `ScrollArea` owns the scroll. Shell/log output (`execute`, `process_output`) and diffs (`write_file`, `edit_files`) intentionally keep **wrapping**. | Preview type | Behavior | | --- | --- | | `read_file`, generic/MCP input & output, markdown fenced code | horizontal **scroll** (new) | | `execute`, `process_output` (shell/logs) | wrap (unchanged) | | `write_file`, `edit_files` (diffs) | wrap (unchanged) | ## Changes - `ScrollArea`: add `horizontalScrollBarClassName` to size the horizontal bar independently of the vertical bar (avoids a `twMerge` width/height conflict). - `ReadFileTool`, generic `ToolFileViewer` (`Tool.tsx`): `orientation="both"` + thin horizontal bar. - `Response.tsx`: wrap fenced code in a both-axis `ScrollArea`; let `[data-code]` size to content. - Long-line regression stories for the file viewer, generic tool, and markdown. - Change scrollbar color to accessible contrast ratio - Increase hit area for scroll bars to 24px which is minimim for wcag 2.2 accessibility requirements
Implementation notes & decisions - The `@pierre/diffs` `File` viewer only supports `overflow: "scroll" | "wrap"`. Its `[data-code]` element is a grid with `overflow: scroll clip` that grows to content width instead of scrolling, so the outer container must provide the scroll affordance. - Chosen approach: surface the **outer** `ScrollArea`'s horizontal scrollbar (the viewport was already scrollable) rather than fighting the library's internal per-block scroll. This yields a single, unified horizontal scrollbar and guarantees the timeline never exceeds the viewport (the `ScrollArea` root is `overflow: hidden`). - Scroll vs wrap was chosen per content type: code/JSON/file structure benefits from scrolling (wrapping breaks indentation and line-number alignment), while shell/log output keeps wrapping. Precedent for visible horizontal scrollbars already exists in `GitPanel`/`TaskApps`.
--- _Generated by Coder Agents on behalf of @jaaydenh._ --- .../ScrollArea/ScrollArea.stories.tsx | 106 ++++++++++++++++++ site/src/components/ScrollArea/ScrollArea.tsx | 47 +++++--- .../ChatElements/Response.stories.tsx | 62 ++++++++++ .../components/ChatElements/Response.tsx | 13 ++- .../tools/ExecuteTool.stories.tsx | 30 +++++ .../ChatElements/tools/ExecuteTool.tsx | 4 +- .../ChatElements/tools/ReadFileTool.tsx | 2 + .../ChatElements/tools/Tool.stories.tsx | 82 ++++++++++++++ .../components/ChatElements/tools/Tool.tsx | 2 + .../components/ChatElements/tools/utils.ts | 6 +- 10 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 site/src/components/ScrollArea/ScrollArea.stories.tsx diff --git a/site/src/components/ScrollArea/ScrollArea.stories.tsx b/site/src/components/ScrollArea/ScrollArea.stories.tsx new file mode 100644 index 0000000000..44e82a14d7 --- /dev/null +++ b/site/src/components/ScrollArea/ScrollArea.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, waitFor } from "storybook/test"; +import { ScrollArea } from "./ScrollArea"; + +const meta: Meta = { + title: "components/ScrollArea", + component: ScrollArea, +}; +export default meta; +type Story = StoryObj; + +const OverflowingContent = () => ( +
+ {Array.from({ length: 60 }, (_, row) => ( +
+ {`row ${row.toString().padStart(2, "0")} `} + {"value ".repeat(30)} +
+ ))} +
+); + +const luminance = (color: string): number => { + const parts = (color.match(/[\d.]+/g) ?? []).map(Number); + const [r, g, b] = parts.slice(0, 3).map((value) => { + const channel = value / 255; + return channel <= 0.03928 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +const contrastRatio = (a: string, b: string): number => { + const la = luminance(a); + const lb = luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +}; + +export const Accessibility: Story = { + render: () => ( +
+ + + +
+ ), + play: async ({ canvasElement }) => { + const surface = canvasElement.querySelector( + "[data-testid='surface']", + ); + await expect(surface).not.toBeNull(); + + const getThumbs = () => { + const vertical = canvasElement.querySelector( + '[data-orientation="vertical"]', + )?.firstElementChild as HTMLElement | null | undefined; + const horizontal = canvasElement.querySelector( + '[data-orientation="horizontal"]', + )?.firstElementChild as HTMLElement | null | undefined; + return { vertical, horizontal }; + }; + + await waitFor(() => { + const { vertical, horizontal } = getThumbs(); + expect(vertical).toBeTruthy(); + expect(horizontal).toBeTruthy(); + }); + + const { vertical, horizontal } = getThumbs(); + if (!vertical || !horizontal || !surface) { + throw new Error("scrollbar thumbs not found"); + } + + const verticalBefore = getComputedStyle(vertical, "::before"); + await expect( + Number.parseFloat(verticalBefore.width), + ).toBeGreaterThanOrEqual(24); + await expect( + Number.parseFloat(verticalBefore.height), + ).toBeGreaterThanOrEqual(24); + + const horizontalBefore = getComputedStyle(horizontal, "::before"); + await expect( + Number.parseFloat(horizontalBefore.width), + ).toBeGreaterThanOrEqual(24); + await expect( + Number.parseFloat(horizontalBefore.height), + ).toBeGreaterThanOrEqual(24); + + const thumbColor = getComputedStyle(vertical).backgroundColor; + const surfaceColor = getComputedStyle(surface).backgroundColor; + await expect( + contrastRatio(thumbColor, surfaceColor), + ).toBeGreaterThanOrEqual(3); + }, +}; diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index f1c7992255..c20fabd541 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -3,12 +3,13 @@ * @see {@link https://ui.shadcn.com/docs/components/scroll-area} */ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; -import { useCallback, useRef } from "react"; +import { useEffect, useRef } from "react"; import { cn } from "#/utils/cn"; interface ScrollAreaProps extends React.ComponentPropsWithRef { scrollBarClassName?: string; + horizontalScrollBarClassName?: string; viewportClassName?: string; viewportTabIndex?: number; /** Which scrollbar(s) to show. Defaults to "vertical". */ @@ -18,6 +19,7 @@ interface ScrollAreaProps export const ScrollArea: React.FC = ({ className, scrollBarClassName, + horizontalScrollBarClassName, viewportClassName, viewportTabIndex, orientation = "vertical", @@ -26,21 +28,22 @@ export const ScrollArea: React.FC = ({ }) => { const viewportRef = useRef(null); - // Translate vertical wheel events into horizontal scroll when the - // scroll area only scrolls horizontally. Without this, the mouse - // wheel does nothing on a horizontal-only container. - const handleWheel = useCallback( - (e: React.WheelEvent) => { - if (orientation !== "horizontal") return; - const el = viewportRef.current; - if (!el) return; - // Only redirect when the user is scrolling vertically. + useEffect(() => { + const el = viewportRef.current; + if (!el || orientation === "vertical") return; + const handleWheel = (e: WheelEvent) => { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + if (el.scrollWidth <= el.clientWidth) return; + if (el.scrollHeight > el.clientHeight) return; + const maxLeft = el.scrollWidth - el.clientWidth; + if (e.deltaY > 0 && el.scrollLeft >= maxLeft) return; + if (e.deltaY < 0 && el.scrollLeft <= 0) return; e.preventDefault(); el.scrollBy({ left: e.deltaY, behavior: "smooth" }); - }, - [orientation], - ); + }; + el.addEventListener("wheel", handleWheel, { passive: false }); + return () => el.removeEventListener("wheel", handleWheel); + }, [orientation]); return ( = ({ {children} @@ -64,7 +66,12 @@ export const ScrollArea: React.FC = ({ {(orientation === "horizontal" || orientation === "both") && ( )} @@ -88,7 +95,15 @@ export const ScrollBar: React.FC< )} {...props} > - + ); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx index 4efeaef10d..17b6101448 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/Response.stories.tsx @@ -144,6 +144,7 @@ const expectCodeBlock = async ( expect(codeStyles.paddingTop).toBe("8px"); expect(codeStyles.paddingBottom).toBe("8px"); expect(codeStyles.paddingBottom).toBe(codeStyles.paddingTop); + expect(codeStyles.overflow).toBe("visible"); const lineStyles = getComputedStyle(line); expect(lineStyles.paddingLeft).toBe("12px"); @@ -184,6 +185,67 @@ export const SingleLineFencedBlock: Story = { }, }; +const longLineCodeBlockMarkdown = [ + "```ts", + 'const config = { apiUrl: "https://coder.example.com/api/v2/workspaces", token: "abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", retries: 5 };', + "```", + "", +].join("\n"); + +export const LongLineFencedBlock: Story = { + args: { + children: longLineCodeBlockMarkdown, + }, + play: async ({ canvasElement }) => { + await expectCodeBlock(canvasElement, /apiUrl/); + const viewport = [ + ...canvasElement.querySelectorAll( + "[data-radix-scroll-area-viewport]", + ), + ].find((v) => v.scrollWidth > v.clientWidth); + if (!viewport) { + throw new Error("Expected a horizontally scrollable viewport."); + } + viewport.dispatchEvent( + new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true }), + ); + await waitFor(() => expect(viewport.scrollLeft).toBeGreaterThan(0)); + }, +}; + +export const LongLineFencedBlockWheelEdges: Story = { + args: { + children: longLineCodeBlockMarkdown, + }, + play: async ({ canvasElement }) => { + await expectCodeBlock(canvasElement, /apiUrl/); + const viewport = [ + ...canvasElement.querySelectorAll( + "[data-radix-scroll-area-viewport]", + ), + ].find((v) => v.scrollWidth > v.clientWidth); + if (!viewport) { + throw new Error("Expected a horizontally scrollable viewport."); + } + const dispatchWheel = (deltaY: number) => { + const event = new WheelEvent("wheel", { + deltaY, + bubbles: true, + cancelable: true, + }); + viewport.dispatchEvent(event); + return event.defaultPrevented; + }; + const maxLeft = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = Math.floor(maxLeft / 2); + expect(dispatchWheel(200)).toBe(true); + viewport.scrollLeft = maxLeft; + expect(dispatchWheel(200)).toBe(false); + viewport.scrollLeft = 0; + expect(dispatchWheel(-200)).toBe(false); + }, +}; + export const MarkdownAndLinksLight: Story = { globals: { theme: "light", diff --git a/site/src/pages/AgentsPage/components/ChatElements/Response.tsx b/site/src/pages/AgentsPage/components/ChatElements/Response.tsx index f18dcef1d7..5975b20864 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/Response.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/Response.tsx @@ -10,6 +10,7 @@ import { Streamdown, type UrlTransform, } from "streamdown"; +import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { cn } from "#/utils/cn"; interface ResponseProps extends Omit, "children"> { @@ -86,8 +87,7 @@ type FileViewerThemeType = keyof typeof fileViewerTheme; const markdownFileViewerCSS = [ ":host { background-color: transparent !important; }", "pre, [data-code], [data-line], [data-diffs-header] { background-color: transparent !important; }", - "[data-code] { padding-block: 8px !important; overflow: auto clip !important; scrollbar-width: none !important; }", - "[data-code]::-webkit-scrollbar { width: 0 !important; height: 0 !important; }", + "[data-code] { padding-block: 8px !important; overflow: visible !important; }", "[data-disable-line-numbers][data-file] { --diffs-grid-number-column-width: 0px !important; }", "[data-disable-line-numbers] [data-column-number] { min-width: 0 !important; padding: 0 !important; }", "[data-line] { min-height: 20px !important; padding-inline: 12px !important; }", @@ -216,7 +216,12 @@ const createComponents = ( const content = getHastText(codeChild).trimEnd(); if (content) { return ( -
+ -
+ ); } } diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.stories.tsx index 54a47fe050..9b76e0f70a 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.stories.tsx @@ -214,3 +214,33 @@ export const ParsedCommandsWithIntent: Story = { ], }, }; + +export const LongUnbrokenLineOutput: Story = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + command: "cat access-token.txt", + transcriptBlocks: [ + { + kind: "output", + text: `token:${"A".repeat(400)}:end`, + }, + ], + }, + play: async ({ canvasElement }) => { + const viewport = canvasElement.querySelector( + "[data-radix-scroll-area-viewport]", + ); + await expect(viewport).not.toBeNull(); + if (viewport) { + await expect(viewport.scrollWidth).toBeLessThanOrEqual( + viewport.clientWidth + 2, + ); + } + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx index 70f2c9fddf..07d3f4e783 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ExecuteTool.tsx @@ -229,7 +229,7 @@ const ShellTranscriptBody: React.FC<{ scrollBarClassName="w-1.5" >
-
+				
 					
 						$
 					{" "}
@@ -239,7 +239,7 @@ const ShellTranscriptBody: React.FC<{
 					
 			 `const line${i} = ${i};`),
+].join("\n");
+
+export const ReadFileLongLine: Story = {
+	args: {
+		name: "read_file",
+		args: { path: "site/src/config.ts" },
+		result: { content: longCodeLine },
+	},
+	play: async ({ canvasElement }) => {
+		const canvas = within(canvasElement);
+		await userEvent.click(
+			canvas.getByRole("button", { name: /Read config.ts/i }),
+		);
+		await waitFor(() =>
+			expect(getDiffsText(canvasElement)).toContain("apiUrl"),
+		);
+		await waitFor(() => {
+			const host = canvasElement.querySelector("diffs-container");
+			const code = host?.shadowRoot?.querySelector("[data-code]");
+			expect(code).toBeInstanceOf(HTMLElement);
+			if (code instanceof HTMLElement) {
+				expect(getComputedStyle(code).overflow).toBe("visible");
+			}
+		});
+	},
+};
+
+export const ReadFileTallAndWide: Story = {
+	args: {
+		name: "read_file",
+		args: { path: "site/src/config.ts" },
+		result: { content: tallWideFileContent },
+	},
+	play: async ({ canvasElement }) => {
+		const canvas = within(canvasElement);
+		await userEvent.click(
+			canvas.getByRole("button", { name: /Read config.ts/i }),
+		);
+		await waitFor(() =>
+			expect(getDiffsText(canvasElement)).toContain("apiUrl"),
+		);
+		const viewport = [
+			...canvasElement.querySelectorAll(
+				"[data-radix-scroll-area-viewport]",
+			),
+		].find(
+			(v) => v.scrollWidth > v.clientWidth && v.scrollHeight > v.clientHeight,
+		);
+		if (!viewport) {
+			throw new Error("Expected a viewport overflowing on both axes.");
+		}
+		viewport.dispatchEvent(
+			new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true }),
+		);
+		await new Promise((resolve) => setTimeout(resolve, 400));
+		expect(viewport.scrollLeft).toBe(0);
+	},
+};
+
+export const GenericToolLongOutput: Story = {
+	args: {
+		name: "some_custom_tool",
+		args: { query: "lookup" },
+		result: { value: longCodeLine },
+	},
+	play: async ({ canvasElement }) => {
+		const canvas = within(canvasElement);
+		await userEvent.click(
+			canvas.getByRole("button", { name: /some_custom_tool/i }),
+		);
+		await waitFor(() =>
+			expect(getDiffsText(canvasElement)).toContain("apiUrl"),
+		);
+	},
+};
+
 export const SubagentWaitTimedOut: Story = {
 	args: {
 		name: "wait_agent",
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
index 5fce31cff8..e3b15b4466 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.tsx
@@ -832,7 +832,9 @@ const ToolFileViewer: FC = ({ label, file, options }) => (
 		
 			
 		
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
index 8e5595f15f..e8b9d74bb8 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/utils.ts
@@ -309,8 +309,10 @@ export const formatResultOutput = (result: unknown): string | null => {
 	return formatValue(result);
 };
 
-export const fileViewerCSS =
-	"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }";
+export const fileViewerCSS = [
+	"pre, [data-line], [data-diffs-header] { background-color: transparent !important; }",
+	"[data-code] { overflow: visible !important; }",
+].join(" ");
 
 // Selection override CSS maps the library's gold/yellow selection
 // palette to the Coder blue accent (`--content-link`) so line