mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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.
This commit is contained in:
@@ -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<typeof ExecuteTool> = {
|
||||
title: "components/ai-elements/tool/ExecuteTool",
|
||||
component: ExecuteTool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
status: "completed",
|
||||
isError: false,
|
||||
output: "",
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ExecuteTool>;
|
||||
|
||||
/** 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"),
|
||||
},
|
||||
};
|
||||
@@ -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<HTMLPreElement | null>(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 (
|
||||
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
|
||||
{/* Header: $ command + copy button */}
|
||||
<div className="flex w-full items-center justify-between gap-2 px-2.5 py-0.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-xs text-content-secondary">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full justify-between gap-2 px-2.5 py-0.5",
|
||||
commandExpanded ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 gap-2",
|
||||
commandExpanded ? "items-start" : "items-center",
|
||||
canToggleCommand && "cursor-pointer",
|
||||
)}
|
||||
onClick={
|
||||
canToggleCommand ? () => setCommandExpanded((v) => !v) : undefined
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs leading-5 text-content-secondary">
|
||||
$
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs text-content-primary">
|
||||
<code
|
||||
ref={commandRef}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 font-mono text-xs leading-5 text-content-primary",
|
||||
commandExpanded ? "whitespace-pre-wrap break-all" : "truncate",
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{canToggleCommand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandExpanded((v) => !v)}
|
||||
className={cn(
|
||||
"border-0 bg-transparent p-0 m-0 cursor-pointer flex items-center text-content-secondary hover:text-content-primary transition-colors transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
|
||||
commandExpanded
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/exec:opacity-100",
|
||||
)}
|
||||
aria-expanded={commandExpanded}
|
||||
aria-label={
|
||||
commandExpanded ? "Collapse command" : "Expand command"
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
commandExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
{isBackgrounded && !isRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<LayersIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Running in background</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="opacity-0 transition-opacity group-hover/exec:opacity-100">
|
||||
<CopyButton text={command} label="Copy command" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output preview / expanded */}
|
||||
{hasOutput && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user