mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
662d6abfbd
commit
0ad6be7e6e
7 changed files with 496 additions and 308 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
webview-ui/src/components/history/TaskItem.tsx
Normal file
129
webview-ui/src/components/history/TaskItem.tsx
Normal 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)
|
||||
118
webview-ui/src/components/history/TaskItemFooter.tsx
Normal file
118
webview-ui/src/components/history/TaskItemFooter.tsx
Normal 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
|
||||
74
webview-ui/src/components/history/TaskItemHeader.tsx
Normal file
74
webview-ui/src/components/history/TaskItemHeader.tsx
Normal 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
|
||||
|
|
@ -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
webview-ui/src/components/history/__tests__/TaskItem.test.tsx
Normal file
117
webview-ui/src/components/history/__tests__/TaskItem.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue