From 17aea0b19c36d5a6f9b640553ab56cd47cfd4167 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:49:23 +1100 Subject: [PATCH] feat(site): make long execute tool commands expandable (#23562) Previously, long bash commands in the execute tool were truncated with an ellipsis and could not be viewed in full. The only way to see the full command was to copy it via the copy button. Adds overflow detection and an inline expand/collapse chevron next to the copy button. Clicking the command text or the chevron toggles between truncated and wrapped views. Short commands that fit on one line are visually unchanged. https://github.com/user-attachments/assets/88ec6cd4-5212-4608-9a90-9ce217d5dce7 EDIT: couldn't be bothered re-recording the video but the chevron is hidden until hovered now, like the copy button. --- .../ai-elements/tool/ExecuteTool.stories.tsx | 108 ++++++++++++++++++ .../ai-elements/tool/ExecuteTool.tsx | 89 ++++++++++++++- 2 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 site/src/components/ai-elements/tool/ExecuteTool.stories.tsx diff --git a/site/src/components/ai-elements/tool/ExecuteTool.stories.tsx b/site/src/components/ai-elements/tool/ExecuteTool.stories.tsx new file mode 100644 index 0000000000..9b8eda2128 --- /dev/null +++ b/site/src/components/ai-elements/tool/ExecuteTool.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "storybook/test"; +import { ExecuteTool } from "./ExecuteTool"; + +const meta: Meta = { + title: "components/ai-elements/tool/ExecuteTool", + component: ExecuteTool, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + status: "completed", + isError: false, + output: "", + }, +}; +export default meta; +type Story = StoryObj; + +/** A short command that fits on a single line without truncation. */ +export const ShortCommand: Story = { + args: { + command: "git status", + output: "", + }, +}; + +/** A long command expanded to show the full text, with the chevron visible on hover. */ +export const LongCommand: Story = { + args: { + command: + "find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50", + output: "", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const chevron = canvas.getByRole("button", { + name: /expand command/i, + }); + await userEvent.click(chevron); + // Hover the component so the chevron stays visible. + await userEvent.hover(canvasElement.firstElementChild!); + }, +}; + +/** A long truncated command with multi-line output below it. */ +export const LongCommandWithOutput: Story = { + args: { + command: + "find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50", + output: [ + "src/api/legacyClient.ts", + "src/components/OldTable/OldTable.tsx", + "src/hooks/useObsoleteAuth.ts", + "src/pages/SettingsPage/DeprecatedPanel.tsx", + "src/utils/formatDate.ts", + "src/utils/legacyHelpers.ts", + ].join("\n"), + }, +}; + +/** A normal command with multi-line output that overflows the collapsed preview. */ +export const WithOutput: Story = { + args: { + command: "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'", + output: [ + "NAMES STATUS PORTS", + "coder-gateway Up 3 hours 0.0.0.0:3000->3000/tcp", + "coder-database Up 3 hours 0.0.0.0:5432->5432/tcp", + "coder-provisioner Up 3 hours", + "redis-cache Up 3 hours 0.0.0.0:6379->6379/tcp", + "nginx-proxy Up 2 hours 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp", + "prometheus Up 2 hours 0.0.0.0:9090->9090/tcp", + "grafana Up 2 hours 0.0.0.0:3001->3001/tcp", + "jaeger Up 1 hour 0.0.0.0:16686->16686/tcp", + "otel-collector Up 1 hour 0.0.0.0:4317->4317/tcp", + "loki Up 1 hour 0.0.0.0:3100->3100/tcp", + ].join("\n"), + }, +}; + +/** A command currently running shows a spinner in the header. */ +export const Running: Story = { + args: { + command: "go test -race -count=1 ./coderd/...", + status: "running", + output: + "=== RUN TestWorkspaceAgent\n--- PASS: TestWorkspaceAgent (0.42s)", + }, +}; + +/** A command that errored renders the output in red. */ +export const ErrorOutput: Story = { + args: { + command: "make build", + status: "completed", + isError: true, + output: [ + "coderd/workspaces.go:142:6: cannot use ws (variable of type *database.Workspace) as database.Store value in argument to api.Authorize", + "coderd/workspaces.go:155:19: ws.OwnerID undefined (type *database.Workspace has no field or method OwnerID)", + "make: *** [build] Error 1", + ].join("\n"), + }, +}; diff --git a/site/src/components/ai-elements/tool/ExecuteTool.tsx b/site/src/components/ai-elements/tool/ExecuteTool.tsx index 2671d2307f..8a68277ea6 100644 --- a/site/src/components/ai-elements/tool/ExecuteTool.tsx +++ b/site/src/components/ai-elements/tool/ExecuteTool.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon, CircleAlertIcon, ExternalLinkIcon, + LayersIcon, LoaderIcon, TriangleAlertIcon, } from "lucide-react"; @@ -12,6 +13,11 @@ import { cn } from "utils/cn"; import { Button } from "#/components/Button/Button"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { BORDER_BG_STYLE, COLLAPSED_OUTPUT_HEIGHT, @@ -28,12 +34,32 @@ export const ExecuteTool: React.FC<{ output: string; status: ToolStatus; isError: boolean; -}> = ({ command, output, status }) => { + isBackgrounded?: boolean; +}> = ({ command, output, status, isBackgrounded = false }) => { const [expanded, setExpanded] = useState(false); const outputRef = useRef(null); const hasOutput = output.length > 0; const isRunning = status === "running"; + // Track whether the command text is truncated so we can offer + // a click-to-expand interaction. The ResizeObserver may clear + // commandOverflows while the text is wrapped, but + // canToggleCommand stays true via commandExpanded so the + // collapse affordance remains visible. + const [commandExpanded, setCommandExpanded] = useState(false); + const [commandOverflows, setCommandOverflows] = useState(false); + const canToggleCommand = commandOverflows || commandExpanded; + const commandRef = (node: HTMLElement | null) => { + if (!node) return; + const measure = () => { + setCommandOverflows(node.scrollWidth > node.clientWidth); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(node); + return () => ro.disconnect(); + }; + // Check whether the output overflows the collapsed height so we // know if we need to show the expand toggle at all. const [overflows, setOverflows] = useState(false); @@ -47,25 +73,76 @@ export const ExecuteTool: React.FC<{ return (
{/* Header: $ command + copy button */} -
-
- +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */} +
setCommandExpanded((v) => !v) : undefined + } + > + $ - + {command}
+ {canToggleCommand && ( + + )} {isRunning && ( )} + {isBackgrounded && !isRunning && ( + + + + + Running in background + + )}
- {/* Output preview / expanded */} {hasOutput && ( <>