Просмотр исходного кода

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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Eric Wheeler <[email protected]>
Daniel 7 месяцев назад
Родитель
Сommit
0ad6be7e6e

+ 11 - 57
webview-ui/src/components/history/HistoryPreview.tsx

@@ -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>
 	)
 }
 

+ 11 - 241
webview-ui/src/components/history/HistoryView.tsx

@@ -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>

+ 129 - 0
webview-ui/src/components/history/TaskItem.tsx

@@ -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)

+ 118 - 0
webview-ui/src/components/history/TaskItemFooter.tsx

@@ -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

+ 74 - 0
webview-ui/src/components/history/TaskItemHeader.tsx

@@ -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

+ 36 - 10
webview-ui/src/components/history/__tests__/HistoryView.test.tsx

@@ -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", () => {

+ 117 - 0
webview-ui/src/components/history/__tests__/TaskItem.test.tsx

@@ -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")
+	})
+})