feat: Add CommandOutputViewer component and integrate it into ChatRow (#2326)

* feat: Add CommandOutputViewer component and integrate it into ChatRow

* Remove unnecessary memo wrapper from CommandOutputViewer component
This commit is contained in:
Sam Hoang Van 2025-04-08 22:29:03 +07:00 committed by GitHub
parent 58149a083e
commit e1f6eb625b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 115 additions and 1 deletions

View file

@ -0,0 +1,63 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import CommandOutputViewer from "../../../components/common/CommandOutputViewer"
// Mock the cn utility function
jest.mock("../../../lib/utils", () => ({
cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
}))
// Mock the Virtuoso component
jest.mock("react-virtuoso", () => ({
Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
<div ref={ref} data-testid="virtuoso-container">
{Array.from({ length: totalCount }).map((_, index) => (
<div key={index} data-testid={`virtuoso-item-${index}`}>
{itemContent(index)}
</div>
))}
</div>
)),
VirtuosoHandle: jest.fn(),
}))
describe("CommandOutputViewer", () => {
it("renders command output with virtualized list", () => {
const testOutput = "Line 1\nLine 2\nLine 3"
render(<CommandOutputViewer output={testOutput} />)
// Check if Virtuoso container is rendered
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
// Check if all lines are rendered
expect(screen.getByText("Line 1")).toBeInTheDocument()
expect(screen.getByText("Line 2")).toBeInTheDocument()
expect(screen.getByText("Line 3")).toBeInTheDocument()
})
it("handles empty output", () => {
render(<CommandOutputViewer output="" />)
// Should still render the container but with no items
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
// No virtuoso items should be rendered for empty string (which creates one empty line)
expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
})
it("handles large output", () => {
// Create a large output with 1000 lines
const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")
render(<CommandOutputViewer output={largeOutput} />)
// Check if Virtuoso container is rendered
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
// Check if first and last lines are rendered
expect(screen.getByText("Line 1")).toBeInTheDocument()
expect(screen.getByText("Line 1000")).toBeInTheDocument()
})
})

View file

@ -16,6 +16,7 @@ import { findMatchingResourceOrTemplate } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import CommandOutputViewer from "../common/CommandOutputViewer"
import MarkdownBlock from "../common/MarkdownBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
@ -917,7 +918,7 @@ export const ChatRowContent = ({
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
</div>
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
{isExpanded && <CommandOutputViewer output={output} />}
</div>
)}
</div>

View file

@ -0,0 +1,50 @@
import { forwardRef, useEffect, useRef } from "react"
import { Virtuoso, VirtuosoHandle } from "react-virtuoso"
import { cn } from "../../lib/utils"
interface CommandOutputViewerProps {
output: string
}
const CommandOutputViewer = forwardRef<HTMLDivElement, CommandOutputViewerProps>(({ output }, ref) => {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const lines = output.split("\n")
useEffect(() => {
// Scroll to the bottom when output changes
if (virtuosoRef.current && typeof virtuosoRef.current.scrollToIndex === "function") {
virtuosoRef.current.scrollToIndex({
index: lines.length - 1,
behavior: "auto",
})
}
}, [output, lines.length])
return (
<div ref={ref} className="w-full rounded-b-md bg-[var(--vscode-editor-background)] h-[300px]">
<Virtuoso
ref={virtuosoRef}
className="h-full"
totalCount={lines.length}
itemContent={(index) => (
<div
className={cn(
"px-3 py-0.5",
"font-mono text-vscode-editor-foreground",
"text-[var(--vscode-editor-font-size,var(--vscode-font-size,12px))]",
"font-[var(--vscode-editor-font-family)]",
"whitespace-pre-wrap break-all anywhere",
)}>
{lines[index]}
</div>
)}
increaseViewportBy={{ top: 300, bottom: 300 }}
followOutput="auto"
/>
</div>
)
})
CommandOutputViewer.displayName = "CommandOutputViewer"
export default CommandOutputViewer