Unify history item UI with TaskItem and TaskItemHeader (#4151)

* refactor: Unify history item UI with TaskItem and TaskItemHeader

Introduces `TaskItem.tsx` and `TaskItemHeader.tsx` to centralize and
standardize the rendering of history entries. `TaskItem` handles the
overall structure for "compact" (Preview) and "full" (HistoryView)
variants. `TaskItemHeader` consolidates all metadata (timestamp, tokens,
cost, cache, file size) into a single, consistent line above the task
content, enhancing visual clarity and reducing UI clutter.

This refactor significantly simplifies `HistoryPreview.tsx` and
`HistoryView.tsx`. Approximately 314 lines of previous rendering logic
were removed from these components and replaced by 242 lines in the new,
focused, and reusable `TaskItem` and `TaskItemHeader` components,
resulting in a net reduction and improved maintainability.

Most importantly, rendering logic happens in one place.

Key UI Changes:
- Metadata (timestamp, tokens, cost, cache, file size) now displayed
  inline on a single header row in both variants.
- Removed explicit "Tokens:" and "API Cost:" labels for a cleaner look.
- Action buttons (Copy, Export, Delete) in the full view are now
  aligned with the metadata header.
- File size is displayed in the header for the "full" variant only.
- Workspace information is no longer displayed in the "compact" preview.

Component Changes:
- Created `webview-ui/src/components/history/TaskItem.tsx` (125 lines)
- Created `webview-ui/src/components/history/TaskItemHeader.tsx` (117 lines)
- Modified `webview-ui/src/components/history/HistoryPreview.tsx` (-65 lines, +3 lines)
- Modified `webview-ui/src/components/history/HistoryView.tsx` (-249 lines, +3 lines)
- Uses `HistoryItem` type for standardized data handling.

Fixes: #4018
Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>

* test: fix TaskItem and HistoryView test failures after refactor

Fixes test failures that occurred after the major refactoring that introduced
the shared TaskItem component. The original implementation was correct but
the tests needed updates to match the new component structure.

- Add data-testid attributes to TaskItemHeader for reliable test selection
- Update TaskItem.test.tsx assertions to use new test IDs for tokens/cache
- Fix Checkbox import path in TaskItem.tsx (ui/checkbox vs ui)
- Add missing mocks for lucide-react and Checkbox in HistoryView.test.tsx
- Update HistoryView test assertions to use correct selectors
- Ensure all 19 history component tests pass successfully

The refactoring reduced code duplication by ~250+ lines while maintaining
functionality, and these test fixes ensure the quality gates remain intact.

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>

* ui: move token and cost info from header to dedicated footer

Create a new TaskItemFooter component that displays token and cost information
with different styles for compact and full views. Move the CopyButton and
ExportButton from header to footer in full view. Adjust file size display
positioning in the header for better visual alignment.

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>

* test: update history component tests to match implementation

Update data-testid attributes in HistoryView and TaskItem tests to match the
actual implementation in TaskItemFooter component. The tests were looking for
generic "tokens-in" and "tokens-out" attributes, but the implementation uses
variant-specific attributes like "tokens-in-footer-full".

Also added mocks for CopyButton and ExportButton components to resolve
"Element type is invalid" errors during test rendering.

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>

* Move cache information from header to footer to be consistent with cost/token data

* Fix ESLint warnings: remove unused imports and variables

---------

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>
Co-authored-by: Eric Wheeler <roo-code@z.ewheeler.org>
This commit is contained in:
Daniel 2025-06-01 22:14:20 -05:00 committed by GitHub
parent 662d6abfbd
commit 0ad6be7e6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 496 additions and 308 deletions

View file

@ -1,67 +1,21 @@
import { memo } from "react"
import { vscode } from "@/utils/vscode"
import { formatLargeNumber, formatDate } from "@/utils/format"
import { CopyButton } from "./CopyButton"
import { useTaskSearch } from "./useTaskSearch"
import { Coins } from "lucide-react"
import TaskItem from "./TaskItem"
const HistoryPreview = () => {
const { tasks, showAllWorkspaces } = useTaskSearch()
const { tasks } = useTaskSearch()
return (
<>
<div className="flex flex-col gap-3">
{tasks.length !== 0 && (
<>
{tasks.slice(0, 3).map((item) => (
<div
key={item.id}
className="bg-vscode-editor-background rounded relative overflow-hidden cursor-pointer border border-vscode-toolbar-hoverBackground/30 hover:border-vscode-toolbar-hoverBackground/60"
onClick={() => vscode.postMessage({ type: "showTaskWithId", text: item.id })}>
<div className="flex flex-col gap-2 p-3 pt-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-vscode-descriptionForeground uppercase">
{formatDate(item.ts)}
</span>
<CopyButton itemTask={item.task} />
</div>
<div
className="text-vscode-foreground overflow-hidden whitespace-pre-wrap"
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{item.task}
</div>
<div className="flex flex-row gap-2 text-xs text-vscode-descriptionForeground">
<span> {formatLargeNumber(item.tokensIn || 0)}</span>
<span> {formatLargeNumber(item.tokensOut || 0)}</span>
{!!item.totalCost && (
<span>
<Coins className="inline-block size-[1em]" />{" "}
{"$" + item.totalCost?.toFixed(2)}
</span>
)}
</div>
{showAllWorkspaces && item.workspace && (
<div className="flex flex-row gap-1 text-vscode-descriptionForeground text-xs mt-1">
<span className="codicon codicon-folder scale-80" />
<span>{item.workspace}</span>
</div>
)}
</div>
</div>
))}
</>
)}
</div>
</>
<div className="flex flex-col gap-3">
{tasks.length !== 0 && (
<>
{tasks.slice(0, 3).map((item) => (
<TaskItem key={item.id} item={item} variant="compact" />
))}
</>
)}
</div>
)
}

View file

@ -1,21 +1,17 @@
import React, { memo, useState } from "react"
import { DeleteTaskDialog } from "./DeleteTaskDialog"
import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog"
import prettyBytes from "pretty-bytes"
import { Virtuoso } from "react-virtuoso"
import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@/utils/vscode"
import { formatLargeNumber, formatDate } from "@/utils/format"
import { cn } from "@/lib/utils"
import { Button, Checkbox } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
import { useTaskSearch } from "./useTaskSearch"
import { ExportButton } from "./ExportButton"
import { CopyButton } from "./CopyButton"
import TaskItem from "./TaskItem"
type HistoryViewProps = {
onDone: () => void
@ -210,245 +206,19 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
)),
}}
itemContent={(index, item) => (
<div
data-testid={`task-item-${item.id}`}
<TaskItem
key={item.id}
className={cn("cursor-pointer", {
item={item}
variant="full"
showWorkspace={showAllWorkspaces}
isSelectionMode={isSelectionMode}
isSelected={selectedTaskIds.includes(item.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className={cn({
"border-b border-vscode-panel-border": index < tasks.length - 1,
"bg-vscode-list-activeSelectionBackground":
isSelectionMode && selectedTaskIds.includes(item.id),
})}
onClick={() => {
if (isSelectionMode) {
toggleTaskSelection(item.id, !selectedTaskIds.includes(item.id))
} else {
vscode.postMessage({ type: "showTaskWithId", text: item.id })
}
}}>
<div className="flex items-start p-3 gap-2 ml-2">
{/* Show checkbox in selection mode */}
{isSelectionMode && (
<div
className="task-checkbox mt-1"
onClick={(e) => {
e.stopPropagation()
}}>
<Checkbox
checked={selectedTaskIds.includes(item.id)}
onCheckedChange={(checked) =>
toggleTaskSelection(item.id, checked === true)
}
variant="description"
/>
</div>
)}
<div className="flex-1">
<div className="flex justify-between items-center">
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
{formatDate(item.ts)}
</span>
<div className="flex flex-row">
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
title={t("history:deleteTaskTitle")}
data-testid="delete-task-button"
onClick={(e) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({
type: "deleteTaskWithId",
text: item.id,
})
} else {
setDeleteTaskId(item.id)
}
}}>
<span className="codicon codicon-trash" />
{item.size && prettyBytes(item.size)}
</Button>
)}
</div>
</div>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-foreground)",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
data-testid="task-content"
dangerouslySetInnerHTML={{ __html: item.task }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
data-testid="tokens-container"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
{t("history:tokensLabel")}
</span>
<span
data-testid="tokens-in"
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-up"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span
data-testid="tokens-out"
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-down"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{formatLargeNumber(item.tokensOut || 0)}
</span>
</div>
{!item.totalCost && !isSelectionMode && (
<div className="flex flex-row gap-1">
<CopyButton itemTask={item.task} />
<ExportButton itemId={item.id} />
</div>
)}
</div>
{!!item.cacheWrites && (
<div
data-testid="cache-container"
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
{t("history:cacheLabel")}
</span>
<span
data-testid="cache-writes"
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-database"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+{formatLargeNumber(item.cacheWrites || 0)}
</span>
<span
data-testid="cache-reads"
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-right"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: 0,
}}
/>
{formatLargeNumber(item.cacheReads || 0)}
</span>
</div>
)}
{!!item.totalCost && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: -2,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
{t("history:apiCostLabel")}
</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
${item.totalCost?.toFixed(4)}
</span>
</div>
{!isSelectionMode && (
<div className="flex flex-row gap-1">
<CopyButton itemTask={item.task} />
<ExportButton itemId={item.id} />
</div>
)}
</div>
)}
{showAllWorkspaces && item.workspace && (
<div className="flex flex-row gap-1 text-vscode-descriptionForeground text-xs">
<span className="codicon codicon-folder scale-80" />
<span>{item.workspace}</span>
</div>
)}
</div>
</div>
</div>
</div>
/>
)}
/>
</TabContent>

View file

@ -0,0 +1,129 @@
import { memo } from "react"
import type { HistoryItem } from "@roo-code/types"
import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { useAppTranslation } from "@/i18n/TranslationContext"
import TaskItemHeader from "./TaskItemHeader"
import TaskItemFooter from "./TaskItemFooter"
interface TaskItemProps {
item: HistoryItem
variant: "compact" | "full"
showWorkspace?: boolean
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: (taskId: string, isSelected: boolean) => void
onDelete?: (taskId: string) => void
className?: string
}
const TaskItem = ({
item,
variant,
showWorkspace = false,
isSelectionMode = false,
isSelected = false,
onToggleSelection,
onDelete,
className,
}: TaskItemProps) => {
const { t } = useAppTranslation()
const handleClick = () => {
if (isSelectionMode && onToggleSelection) {
onToggleSelection(item.id, !isSelected)
} else {
vscode.postMessage({ type: "showTaskWithId", text: item.id })
}
}
const isCompact = variant === "compact"
return (
<div
key={item.id}
data-testid={isCompact ? undefined : `task-item-${item.id}`}
className={cn(
"cursor-pointer",
{
// Compact variant styling
"bg-vscode-editor-background rounded relative overflow-hidden border border-vscode-toolbar-hoverBackground/30 hover:border-vscode-toolbar-hoverBackground/60":
isCompact,
// Full variant styling
"bg-vscode-list-activeSelectionBackground": !isCompact && isSelectionMode && isSelected,
},
className,
)}
onClick={handleClick}>
<div
className={cn("flex gap-2", {
"flex-col p-3 pt-1": isCompact,
"items-start p-3 ml-2": !isCompact,
})}>
{/* Selection checkbox - only in full variant */}
{!isCompact && isSelectionMode && (
<div
className="task-checkbox mt-1"
onClick={(e) => {
e.stopPropagation()
}}>
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => onToggleSelection?.(item.id, checked === true)}
variant="description"
/>
</div>
)}
<div className="flex-1">
{/* Header with metadata */}
<TaskItemHeader
item={item}
variant={variant}
isSelectionMode={isSelectionMode}
t={t}
onDelete={onDelete}
/>
{/* Task content */}
<div
className={cn("overflow-hidden whitespace-pre-wrap", {
"text-vscode-foreground": isCompact,
})}
style={{
fontSize: isCompact ? undefined : "var(--vscode-font-size)",
color: isCompact ? undefined : "var(--vscode-foreground)",
display: "-webkit-box",
WebkitLineClamp: isCompact ? 2 : 3,
WebkitBoxOrient: "vertical",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
data-testid={isCompact ? undefined : "task-content"}
{...(isCompact ? {} : { dangerouslySetInnerHTML: { __html: item.task } })}>
{isCompact ? item.task : undefined}
</div>
{/* Task Item Footer */}
<TaskItemFooter item={item} variant={variant} isSelectionMode={isSelectionMode} />
{/* Workspace info */}
{showWorkspace && item.workspace && (
<div
className={cn("flex flex-row gap-1 text-vscode-descriptionForeground text-xs", {
"mt-1": isCompact,
})}>
<span className="codicon codicon-folder scale-80" />
<span>{item.workspace}</span>
</div>
)}
</div>
</div>
</div>
)
}
export default memo(TaskItem)

View file

@ -0,0 +1,118 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { Coins } from "lucide-react"
import { formatLargeNumber } from "@/utils/format"
import { cn } from "@/lib/utils"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { CopyButton } from "./CopyButton"
import { ExportButton } from "./ExportButton"
export interface TaskItemFooterProps {
item: HistoryItem
variant: "compact" | "full"
isSelectionMode?: boolean
}
const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false }) => {
const { t } = useAppTranslation()
const isCompact = variant === "compact"
const metadataIconWithTextAdjustStyle: React.CSSProperties = {
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
verticalAlign: "middle",
marginBottom: "-2px",
fontWeight: "bold",
}
return (
<div
className={cn("text-xs text-vscode-descriptionForeground", {
"mt-2 flex items-center flex-wrap gap-x-2": isCompact,
"mt-1 flex justify-between items-end": !isCompact,
})}>
{isCompact ? (
<>
{/* Compact Cache */}
{!!item.cacheWrites && (
<span className="flex items-center gap-px" data-testid="cache-compact">
<i className="codicon codicon-database" style={metadataIconWithTextAdjustStyle} />
{formatLargeNumber(item.cacheWrites || 0)}
<i className="codicon codicon-arrow-right" style={metadataIconWithTextAdjustStyle} />
{formatLargeNumber(item.cacheReads || 0)}
</span>
)}
{/* Compact Tokens */}
{(item.tokensIn || item.tokensOut) && (
<>
<span data-testid="tokens-in-footer-compact">
{formatLargeNumber(item.tokensIn || 0)}
</span>
<span data-testid="tokens-out-footer-compact">
{formatLargeNumber(item.tokensOut || 0)}
</span>
</>
)}
{/* Compact Cost */}
{!!item.totalCost && (
<span className="flex items-center">
<Coins className="inline-block size-[1em] mr-1" />
<span data-testid="cost-footer-compact">{"$" + item.totalCost.toFixed(2)}</span>
</span>
)}
</>
) : (
<>
<div className="flex flex-col gap-1">
{/* Cache Info */}
{!!item.cacheWrites && (
<div className="flex items-center flex-wrap gap-x-1">
<span className="font-medium">{t("history:cacheLabel")}</span>
<span className="flex items-center gap-px" data-testid="cache-writes">
<i className="codicon codicon-database" style={metadataIconWithTextAdjustStyle} />
<span className="font-medium">{formatLargeNumber(item.cacheWrites || 0)}</span>
</span>
<span className="flex items-center gap-px" data-testid="cache-reads">
<i className="codicon codicon-arrow-right" style={metadataIconWithTextAdjustStyle} />
<span className="font-medium">{formatLargeNumber(item.cacheReads || 0)}</span>
</span>
</div>
)}
{/* Full Tokens */}
{(item.tokensIn || item.tokensOut) && (
<div className="flex items-center flex-wrap gap-x-1">
<span className="font-medium">{t("history:tokensLabel")}</span>
<span className="flex items-center gap-px" data-testid="tokens-in-footer-full">
<i className="codicon codicon-arrow-up" style={metadataIconWithTextAdjustStyle} />
<span className="font-medium">{formatLargeNumber(item.tokensIn || 0)}</span>
</span>
<span className="flex items-center gap-px" data-testid="tokens-out-footer-full">
<i className="codicon codicon-arrow-down" style={metadataIconWithTextAdjustStyle} />
<span className="font-medium">{formatLargeNumber(item.tokensOut || 0)}</span>
</span>
</div>
)}
{/* Full Cost */}
{!!item.totalCost && (
<div className="flex items-center flex-wrap gap-x-1">
<span className="font-medium">{t("history:apiCostLabel")}</span>
<span data-testid="cost-footer-full">{"$" + item.totalCost.toFixed(4)}</span>
</div>
)}
</div>
{/* Action Buttons for non-compact view */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-50 hover:opacity-100">
<CopyButton itemTask={item.task} />
<ExportButton itemId={item.id} />
</div>
)}
</>
)}
</div>
)
}
export default TaskItemFooter

View file

@ -0,0 +1,74 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import prettyBytes from "pretty-bytes"
import { vscode } from "@/utils/vscode"
import { formatDate } from "@/utils/format"
import { Button } from "@/components/ui"
import { CopyButton } from "./CopyButton"
export interface TaskItemHeaderProps {
item: HistoryItem
variant: "compact" | "full"
isSelectionMode: boolean
t: (key: string, options?: any) => string
onDelete?: (taskId: string) => void
}
const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, variant, isSelectionMode, t, onDelete }) => {
const isCompact = variant === "compact"
// Standardized icon styles
const actionIconStyle: React.CSSProperties = {
fontSize: "16px",
color: "var(--vscode-descriptionForeground)",
verticalAlign: "middle",
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
} else if (onDelete) {
onDelete(item.id)
}
}
return (
<div className="flex justify-between items-center pb-0">
<div className="flex items-center flex-wrap gap-x-2 text-xs">
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
{formatDate(item.ts)}
</span>
</div>
{/* Action Buttons */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-50 hover:opacity-100">
{isCompact ? (
<CopyButton itemTask={item.task} />
) : (
<>
{onDelete && (
<Button
variant="ghost"
size="icon"
title={t("history:deleteTaskTitle")}
data-testid="delete-task-button"
onClick={handleDeleteClick}>
<span className="codicon codicon-trash" style={actionIconStyle} />
</Button>
)}
{!isCompact && item.size && (
<span className="text-vscode-descriptionForeground ml-1 text-sm">
{prettyBytes(item.size)}
</span>
)}
</>
)}
</div>
)}
</div>
)
}
export default TaskItemHeader

View file

@ -8,6 +8,20 @@ import { vscode } from "@src/utils/vscode"
jest.mock("@src/context/ExtensionStateContext")
jest.mock("@src/utils/vscode")
jest.mock("@src/i18n/TranslationContext")
jest.mock("@/components/ui/checkbox", () => ({
Checkbox: jest.fn(({ checked, onCheckedChange, ...props }) => (
<input
type="checkbox"
data-testid={props["data-testid"] || "mock-checkbox"}
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
{...props}
/>
)),
}))
jest.mock("lucide-react", () => ({
DollarSign: () => <span data-testid="dollar-sign">$</span>,
}))
jest.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent }: any) => (
<div data-testid="virtuoso-container">
@ -259,8 +273,22 @@ describe("HistoryView", () => {
// Find first task container and check date format
const taskContainer = screen.getByTestId("virtuoso-item-1")
const dateElement = within(taskContainer).getByText((content) => {
return content.includes("FEBRUARY 16") && content.includes("12:00 AM")
// Date is directly in TaskItemHeader, which is a child of TaskItem (rendered by virtuoso)
const dateElement = within(taskContainer).getByText((content, element) => {
if (!element) {
return false
}
const parent = element.parentElement
if (!parent) {
return false
}
return (
element.tagName.toLowerCase() === "span" &&
parent.classList.contains("flex") &&
parent.classList.contains("items-center") &&
content.includes("FEBRUARY 16") &&
content.includes("12:00 AM")
)
})
expect(dateElement).toBeInTheDocument()
})
@ -272,10 +300,9 @@ describe("HistoryView", () => {
// Find first task container
const taskContainer = screen.getByTestId("virtuoso-item-1")
// Find token counts within the task container
const tokensContainer = within(taskContainer).getByTestId("tokens-container")
expect(within(tokensContainer).getByTestId("tokens-in")).toHaveTextContent("100")
expect(within(tokensContainer).getByTestId("tokens-out")).toHaveTextContent("50")
// Find token counts within the task container (TaskItem -> TaskItemFooter)
expect(within(taskContainer).getByTestId("tokens-in-footer-full")).toHaveTextContent("100")
expect(within(taskContainer).getByTestId("tokens-out-footer-full")).toHaveTextContent("50")
})
it("displays cache information when available", () => {
@ -285,10 +312,9 @@ describe("HistoryView", () => {
// Find second task container
const taskContainer = screen.getByTestId("virtuoso-item-2")
// Find cache info within the task container
const cacheContainer = within(taskContainer).getByTestId("cache-container")
expect(within(cacheContainer).getByTestId("cache-writes")).toHaveTextContent("+50")
expect(within(cacheContainer).getByTestId("cache-reads")).toHaveTextContent("25")
// Find cache info within the task container (TaskItem -> TaskItemHeader)
expect(within(taskContainer).getByTestId("cache-writes")).toHaveTextContent("50") // No plus sign in formatLargeNumber
expect(within(taskContainer).getByTestId("cache-reads")).toHaveTextContent("25")
})
it("handles export functionality", () => {

View file

@ -0,0 +1,117 @@
import { render, screen, fireEvent } from "@testing-library/react"
import type { HistoryItem } from "@roo-code/types"
import TaskItem from "../TaskItem"
import { vscode } from "@src/utils/vscode"
jest.mock("@src/utils/vscode")
jest.mock("@src/i18n/TranslationContext")
jest.mock("lucide-react", () => ({
DollarSign: () => <span data-testid="dollar-sign">$</span>,
Coins: () => <span data-testid="coins-icon" />, // Mock for Coins icon used in TaskItemFooter compact
}))
jest.mock("../CopyButton", () => ({
CopyButton: jest.fn(() => <button data-testid="mock-copy-button">Copy</button>),
}))
jest.mock("../ExportButton", () => ({
ExportButton: jest.fn(() => <button data-testid="mock-export-button">Export</button>),
}))
const mockTask: HistoryItem = {
number: 1,
id: "test-task-1",
task: "Test task content",
ts: new Date("2022-02-16T00:00:00").getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "test-workspace",
}
describe("TaskItem", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("renders compact variant correctly", () => {
render(<TaskItem item={mockTask} variant="compact" />)
expect(screen.getByText("Test task content")).toBeInTheDocument()
// Check for tokens display
expect(screen.getByTestId("tokens-in-footer-compact")).toHaveTextContent("100")
expect(screen.getByTestId("tokens-out-footer-compact")).toHaveTextContent("50")
expect(screen.getByTestId("cost-footer-compact")).toHaveTextContent("$0.00") // Cost
})
it("renders full variant correctly", () => {
render(<TaskItem item={mockTask} variant="full" />)
expect(screen.getByTestId("task-item-test-task-1")).toBeInTheDocument()
expect(screen.getByTestId("task-content")).toBeInTheDocument()
expect(screen.getByTestId("tokens-in-footer-full")).toHaveTextContent("100")
expect(screen.getByTestId("tokens-out-footer-full")).toHaveTextContent("50")
})
it("shows workspace when showWorkspace is true", () => {
render(<TaskItem item={mockTask} variant="compact" showWorkspace={true} />)
expect(screen.getByText("test-workspace")).toBeInTheDocument()
})
it("handles click events correctly", () => {
render(<TaskItem item={mockTask} variant="compact" />)
fireEvent.click(screen.getByText("Test task content"))
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "showTaskWithId",
text: "test-task-1",
})
})
it("handles selection mode correctly", () => {
const mockToggleSelection = jest.fn()
render(
<TaskItem
item={mockTask}
variant="full"
isSelectionMode={true}
isSelected={false}
onToggleSelection={mockToggleSelection}
/>,
)
const checkbox = screen.getByRole("checkbox")
expect(checkbox).toBeInTheDocument()
expect(checkbox).not.toBeChecked()
fireEvent.click(screen.getByTestId("task-item-test-task-1"))
expect(mockToggleSelection).toHaveBeenCalledWith("test-task-1", true)
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it("shows delete button in full variant when not in selection mode", () => {
const mockOnDelete = jest.fn()
render(<TaskItem item={mockTask} variant="full" onDelete={mockOnDelete} />)
const deleteButton = screen.getByTestId("delete-task-button")
expect(deleteButton).toBeInTheDocument()
fireEvent.click(deleteButton)
expect(mockOnDelete).toHaveBeenCalledWith("test-task-1")
})
it("displays cache information when available", () => {
const taskWithCache: HistoryItem = {
...mockTask,
cacheWrites: 25,
cacheReads: 10,
}
render(<TaskItem item={taskWithCache} variant="full" />)
expect(screen.getByTestId("cache-writes")).toHaveTextContent("25")
expect(screen.getByTestId("cache-reads")).toHaveTextContent("10")
})
})