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