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

<details>
<summary>Implementation notes & decisions</summary>

- 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`.

</details>

---

_Generated by Coder Agents on behalf of @jaaydenh._
This commit is contained in:
Jaayden Halko
2026-06-08 12:56:19 +01:00
committed by GitHub
parent 3955df796e
commit 70c0ffcfb5
10 changed files with 330 additions and 24 deletions
@@ -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<typeof ScrollArea> = {
title: "components/ScrollArea",
component: ScrollArea,
};
export default meta;
type Story = StoryObj<typeof ScrollArea>;
const OverflowingContent = () => (
<div className="w-[1200px] p-3 font-mono text-xs leading-5">
{Array.from({ length: 60 }, (_, row) => (
<div key={row} className="whitespace-nowrap">
{`row ${row.toString().padStart(2, "0")} `}
{"value ".repeat(30)}
</div>
))}
</div>
);
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: () => (
<div
data-testid="surface"
className="w-96 rounded-md border border-solid border-border-default bg-surface-primary"
>
<ScrollArea
className="h-48"
type="always"
orientation="both"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
>
<OverflowingContent />
</ScrollArea>
</div>
),
play: async ({ canvasElement }) => {
const surface = canvasElement.querySelector<HTMLElement>(
"[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);
},
};
+31 -16
View File
@@ -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<typeof ScrollAreaPrimitive.Root> {
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<ScrollAreaProps> = ({
className,
scrollBarClassName,
horizontalScrollBarClassName,
viewportClassName,
viewportTabIndex,
orientation = "vertical",
@@ -26,21 +28,22 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
}) => {
const viewportRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<ScrollAreaPrimitive.Root
@@ -50,7 +53,6 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
tabIndex={viewportTabIndex}
onWheel={handleWheel}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
{children}
@@ -64,7 +66,12 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
{(orientation === "horizontal" || orientation === "both") && (
<ScrollBar
orientation="horizontal"
className={cn("z-10", scrollBarClassName)}
className={cn(
"z-10",
orientation === "both"
? horizontalScrollBarClassName
: (horizontalScrollBarClassName ?? scrollBarClassName),
)}
/>
)}
<ScrollAreaPrimitive.Corner />
@@ -88,7 +95,15 @@ export const ScrollBar: React.FC<
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
<ScrollAreaPrimitive.ScrollAreaThumb
className={cn(
"relative flex-1 rounded-full bg-surface-invert-secondary",
"before:absolute before:content-['']",
orientation === "vertical"
? "before:right-0 before:top-1/2 before:h-full before:min-h-6 before:w-6 before:-translate-y-1/2"
: "before:bottom-0 before:left-1/2 before:w-full before:min-w-6 before:h-6 before:-translate-x-1/2",
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
};
@@ -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<HTMLElement>(
"[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<HTMLElement>(
"[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",
@@ -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<ComponentPropsWithRef<"div">, "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 (
<div className="my-4 overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
<ScrollArea
orientation="both"
className="my-4 rounded-md border border-solid border-border-default bg-surface-primary"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
>
<FileViewer
file={{
name: `block.${lang}`,
@@ -234,7 +239,7 @@ const createComponents = (
}}
style={markdownFileViewerStyle}
/>
</div>
</ScrollArea>
);
}
}
@@ -214,3 +214,33 @@ export const ParsedCommandsWithIntent: Story = {
],
},
};
export const LongUnbrokenLineOutput: Story = {
decorators: [
(Story) => (
<div className="w-72">
<Story />
</div>
),
],
args: {
command: "cat access-token.txt",
transcriptBlocks: [
{
kind: "output",
text: `token:${"A".repeat(400)}:end`,
},
],
},
play: async ({ canvasElement }) => {
const viewport = canvasElement.querySelector<HTMLElement>(
"[data-radix-scroll-area-viewport]",
);
await expect(viewport).not.toBeNull();
if (viewport) {
await expect(viewport.scrollWidth).toBeLessThanOrEqual(
viewport.clientWidth + 2,
);
}
},
};
@@ -229,7 +229,7 @@ const ShellTranscriptBody: React.FC<{
scrollBarClassName="w-1.5"
>
<div className="px-3 py-2.5">
<pre className="m-0 whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-xs font-semibold leading-5 text-content-primary">
<pre className="m-0 whitespace-pre-wrap break-all border-0 bg-transparent p-0 font-mono text-xs font-semibold leading-5 text-content-primary">
<span aria-hidden className="select-none">
$
</span>{" "}
@@ -239,7 +239,7 @@ const ShellTranscriptBody: React.FC<{
<pre
key={block.kind}
className={cn(
"m-0 mt-4 whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-xs font-normal leading-5",
"m-0 mt-4 whitespace-pre-wrap break-all border-0 bg-transparent p-0 font-mono text-xs font-normal leading-5",
block.kind === "error" || isError
? "text-content-destructive"
: "text-content-secondary",
@@ -29,7 +29,9 @@ const ReadFileContent: React.FC<{
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
orientation="both"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
>
<FileViewer
file={{
@@ -2065,6 +2065,88 @@ export const GenericToolFailedNoResult: Story = {
},
};
const longCodeLine =
'export const config = { apiUrl: "https://coder.example.com/api/v2/workspaces", token: "abcdefghijklmnopqrstuvwxyz0123456789_ABCDEFGHIJKLMNOPQRSTUVWXYZ_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", retries: 5 };';
const tallWideFileContent = [
longCodeLine,
...Array.from({ length: 40 }, (_, i) => `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<HTMLElement>(
"[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",
@@ -832,7 +832,9 @@ const ToolFileViewer: FC<ToolFileViewerProps> = ({ label, file, options }) => (
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName="max-h-64"
orientation="both"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
>
<FileViewer file={file} options={options} style={DIFFS_FONT_STYLE} />
</ScrollArea>
@@ -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