mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix(site): refine execute tool transcript UI (#25432)
This commit is contained in:
+4
-5
@@ -2031,14 +2031,13 @@ export const ToolDisplayModesFromPreferences: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("pnpm test")).toBeVisible();
|
||||
const commandOutputButton = canvas.getByRole("button", {
|
||||
name: "Expand command",
|
||||
});
|
||||
expect(commandOutputButton).toHaveTextContent("Ran pnpm test");
|
||||
expect(canvas.queryByText("tests passed")).not.toBeInTheDocument();
|
||||
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
|
||||
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
|
||||
|
||||
const commandOutputButton = canvas.getByRole("button", {
|
||||
name: "Expand command output",
|
||||
});
|
||||
expect(commandOutputButton).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(commandOutputButton);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, screen, userEvent, within } from "storybook/test";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { ExecuteTool } from "./ExecuteTool";
|
||||
|
||||
const longCommand =
|
||||
@@ -30,6 +30,13 @@ export const ShortCommand: Story = {
|
||||
command: "git status",
|
||||
output: "",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const summaryButton = await canvas.findByRole("button", {
|
||||
name: "Expand command",
|
||||
});
|
||||
await userEvent.click(summaryButton);
|
||||
},
|
||||
};
|
||||
|
||||
export const RunningWithoutCommand: Story = {
|
||||
@@ -38,31 +45,26 @@ export const RunningWithoutCommand: Story = {
|
||||
status: "running",
|
||||
output: "",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText("$")).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "Copy command" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const LongCommand: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-72">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
command: longCommand,
|
||||
output: "",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const command = canvas.getByText(longCommand);
|
||||
expect(command).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: longCommand }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.hover(command);
|
||||
expect(
|
||||
await screen.findByRole("tooltip", undefined, { timeout: 2000 }),
|
||||
).toHaveTextContent(longCommand);
|
||||
const summaryButton = await canvas.findByRole("button", {
|
||||
name: "Expand command",
|
||||
});
|
||||
await userEvent.click(summaryButton);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LayersIcon,
|
||||
LoaderIcon,
|
||||
OctagonXIcon,
|
||||
TerminalIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
@@ -79,13 +78,10 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
|
||||
outputInitiallyOpen,
|
||||
}) => {
|
||||
const hasCommand = command.trim().length > 0;
|
||||
const hasOutput = output.length > 0;
|
||||
const isRunning = status === "running";
|
||||
const showFailureIndicator = isError && !isRunning;
|
||||
const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
|
||||
const outputToggleLabel = outputOpen
|
||||
? "Collapse command output"
|
||||
: "Expand command output";
|
||||
const outputToggleLabel = outputOpen ? "Collapse command" : "Expand command";
|
||||
const durationLabel = formatShellDurationMs(durationMs);
|
||||
|
||||
if (!hasCommand) {
|
||||
@@ -93,36 +89,20 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-mono font-normal text-xs leading-5">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
{hasOutput ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={outputOpen}
|
||||
aria-label={outputToggleLabel}
|
||||
onClick={() => setOutputOpen((value) => !value)}
|
||||
className="col-start-1 row-start-1 m-0 flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left font-[inherit] font-normal text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ShellCommandLine
|
||||
command={command}
|
||||
durationLabel={durationLabel}
|
||||
expanded={outputOpen}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-2 font-normal text-content-secondary">
|
||||
<ShellCommandLine
|
||||
command={command}
|
||||
durationLabel={durationLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xl whitespace-pre-wrap break-all font-mono font-normal">
|
||||
{command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-sans font-normal text-xs leading-5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={outputOpen}
|
||||
aria-label={outputToggleLabel}
|
||||
onClick={() => setOutputOpen((value) => !value)}
|
||||
className="col-start-1 row-start-1 m-0 flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left font-[inherit] font-normal text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ShellCommandLine
|
||||
command={command}
|
||||
durationLabel={durationLabel}
|
||||
expanded={outputOpen}
|
||||
/>
|
||||
</button>
|
||||
<div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
@@ -174,8 +154,12 @@ const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
|
||||
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{hasOutput && outputOpen && (
|
||||
<ShellOutputBody output={output} isError={isError} />
|
||||
{outputOpen && (
|
||||
<ShellTranscriptBody
|
||||
command={command}
|
||||
output={output}
|
||||
isError={isError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -188,9 +172,8 @@ const ShellCommandLine: React.FC<{
|
||||
}> = ({ command, durationLabel, expanded }) => {
|
||||
return (
|
||||
<>
|
||||
<TerminalIcon aria-hidden className="size-3.5 shrink-0 text-current" />
|
||||
<span className="block min-w-0 truncate text-[13px] font-normal text-current">
|
||||
{command}
|
||||
Ran {command}
|
||||
</span>
|
||||
{durationLabel && (
|
||||
<span className="shrink-0 text-[13px] font-normal text-content-secondary">
|
||||
@@ -209,24 +192,35 @@ const ShellCommandLine: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const ShellOutputBody: React.FC<{
|
||||
const ShellTranscriptBody: React.FC<{
|
||||
command: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
}> = ({ output, isError }) => {
|
||||
}> = ({ command, output, isError }) => {
|
||||
return (
|
||||
<ScrollArea
|
||||
className="col-start-1 col-span-2 mt-1 rounded-md border border-solid border-border-default/50 bg-surface-secondary/30 text-2xs"
|
||||
className="col-start-1 col-span-2 mt-2 rounded-xl bg-surface-secondary/60 text-2xs"
|
||||
viewportClassName="max-h-64"
|
||||
scrollBarClassName="w-1.5"
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"m-0 whitespace-pre-wrap break-all border-0 bg-transparent px-2 py-1.5 font-mono text-xs leading-5",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
<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">
|
||||
<span aria-hidden className="select-none">
|
||||
$
|
||||
</span>{" "}
|
||||
{command}
|
||||
</pre>
|
||||
{output.length > 0 && (
|
||||
<pre
|
||||
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",
|
||||
isError ? "text-content-destructive" : "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ExecuteError: Story = {
|
||||
expect(canvas.getByRole("img", { name: "Command failed" })).toBeVisible();
|
||||
expect(canvas.queryByText("exit 1")).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Expand command output" }),
|
||||
canvas.getByRole("button", { name: "Expand command" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/error line 1/)).toBeVisible();
|
||||
@@ -144,13 +144,16 @@ export const ExecuteAlwaysCollapsed: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(executeCommand)).toBeVisible();
|
||||
const commandButton = canvas.getByRole("button", {
|
||||
name: "Expand command",
|
||||
});
|
||||
expect(commandButton).toHaveTextContent(`Ran ${executeCommand}`);
|
||||
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByText(/From github\.com:coder\/coder/),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByText(executeCommand));
|
||||
await userEvent.click(commandButton);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
|
||||
});
|
||||
@@ -174,11 +177,11 @@ export const ExecuteLongCommandCollapsed: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const command = canvas.getByText(longExecuteCommand);
|
||||
expect(command).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: longExecuteCommand }),
|
||||
).not.toBeInTheDocument();
|
||||
const commandButton = canvas.getByRole("button", {
|
||||
name: "Expand command",
|
||||
});
|
||||
expect(commandButton).toHaveTextContent(`Ran ${longExecuteCommand}`);
|
||||
expect(commandButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
|
||||
expect(canvas.getByText("47.2s")).toBeVisible();
|
||||
expect(canvas.queryByText("61 lines")).not.toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user