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 && ( <>