refactor: more consistent history UI (#4684)

This commit is contained in:
Dicha Zelianivan Arkana 2025-06-14 10:53:58 +07:00 committed by GitHub
parent f51f89454f
commit e618fa958c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1436 additions and 763 deletions

View file

@ -2,15 +2,14 @@ import { useCallback } from "react"
import { useClipboard } from "@/components/ui/hooks"
import { Button } from "@/components/ui"
import { cn } from "@/lib/utils"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { cn } from "@/lib/utils"
type CopyButtonProps = {
itemTask: string
className?: string
}
export const CopyButton = ({ itemTask, className }: CopyButtonProps) => {
export const CopyButton = ({ itemTask }: CopyButtonProps) => {
const { isCopied, copy } = useClipboard()
const { t } = useAppTranslation()
@ -31,8 +30,8 @@ export const CopyButton = ({ itemTask, className }: CopyButtonProps) => {
size="icon"
title={t("history:copyPrompt")}
onClick={onCopy}
data-testid="copy-prompt-button"
className={cn("opacity-50 hover:opacity-100", className)}>
className="group-hover:opacity-100 opacity-50 transition-opacity"
data-testid="copy-prompt-button">
<span className={cn("codicon scale-80", { "codicon-check": isCopied, "codicon-copy": !isCopied })} />
</Button>
)

View file

@ -0,0 +1,38 @@
import { useCallback } from "react"
import { Button } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
type DeleteButtonProps = {
itemId: string
onDelete?: (taskId: string) => void
}
export const DeleteButton = ({ itemId, onDelete }: DeleteButtonProps) => {
const { t } = useAppTranslation()
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: itemId })
} else if (onDelete) {
onDelete(itemId)
}
},
[itemId, onDelete],
)
return (
<Button
variant="ghost"
size="icon"
title={t("history:deleteTaskTitle")}
data-testid="delete-task-button"
onClick={handleDeleteClick}
className="group-hover:opacity-100 opacity-50 transition-opacity">
<span className="codicon codicon-trash size-4 align-middle text-vscode-descriptionForeground" />
</Button>
)
}

View file

@ -1,21 +1,28 @@
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useCallback } from "react"
export const ExportButton = ({ itemId }: { itemId: string }) => {
const { t } = useAppTranslation()
const handleExportClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
},
[itemId],
)
return (
<Button
data-testid="export"
variant="ghost"
size="icon"
title={t("history:exportTask")}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<span className="codicon codicon-desktop-download" />
className="group-hover:opacity-100 opacity-50 transition-opacity"
onClick={handleExportClick}>
<span className="codicon codicon-desktop-download scale-80" />
</Button>
)
}

View file

@ -3,10 +3,9 @@ import { DeleteTaskDialog } from "./DeleteTaskDialog"
import { BatchDeleteTaskDialog } from "./BatchDeleteTaskDialog"
import { Virtuoso } from "react-virtuoso"
import { VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { cn } from "@/lib/utils"
import { Button, Checkbox } from "@/components/ui"
import { Button, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Tab, TabContent, TabHeader } from "../common/Tab"
@ -95,7 +94,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</div>
<div className="flex flex-col gap-2">
<VSCodeTextField
style={{ width: "100%" }}
className="w-full"
placeholder={t("history:searchPlaceholder")}
value={searchQuery}
data-testid="history-search-input"
@ -107,62 +106,83 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setSortOption("mostRelevant")
}
}}>
<div
slot="start"
className="codicon codicon-search"
style={{ fontSize: 13, marginTop: 2.5, opacity: 0.8 }}
/>
<div slot="start" className="codicon codicon-search mt-0.5 opacity-80 text-sm!" />
{searchQuery && (
<div
className="input-icon-button codicon codicon-close"
className="input-icon-button codicon codicon-close flex justify-center items-center h-full"
aria-label="Clear search"
onClick={() => setSearchQuery("")}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
<VSCodeRadioGroup
style={{ display: "flex", flexWrap: "wrap" }}
value={sortOption}
role="radiogroup"
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
<VSCodeRadio value="newest" data-testid="radio-newest">
{t("history:newest")}
</VSCodeRadio>
<VSCodeRadio value="oldest" data-testid="radio-oldest">
{t("history:oldest")}
</VSCodeRadio>
<VSCodeRadio value="mostExpensive" data-testid="radio-most-expensive">
{t("history:mostExpensive")}
</VSCodeRadio>
<VSCodeRadio value="mostTokens" data-testid="radio-most-tokens">
{t("history:mostTokens")}
</VSCodeRadio>
<VSCodeRadio
value="mostRelevant"
disabled={!searchQuery}
data-testid="radio-most-relevant"
style={{ opacity: searchQuery ? 1 : 0.5 }}>
{t("history:mostRelevant")}
</VSCodeRadio>
</VSCodeRadioGroup>
<div className="flex items-center gap-2">
<Checkbox
id="show-all-workspaces-view"
checked={showAllWorkspaces}
onCheckedChange={(checked) => setShowAllWorkspaces(checked === true)}
variant="description"
/>
<label htmlFor="show-all-workspaces-view" className="text-vscode-foreground cursor-pointer">
{t("history:showAllWorkspaces")}
</label>
<div className="flex gap-2">
<Select
value={showAllWorkspaces ? "all" : "current"}
onValueChange={(value) => setShowAllWorkspaces(value === "all")}>
<SelectTrigger className="flex-1">
<SelectValue>
{t("history:workspace.prefix")}{" "}
{t(`history:workspace.${showAllWorkspaces ? "all" : "current"}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="current">
<div className="flex items-center gap-2">
<span className="codicon codicon-folder" />
{t("history:workspace.current")}
</div>
</SelectItem>
<SelectItem value="all">
<div className="flex items-center gap-2">
<span className="codicon codicon-folder-opened" />
{t("history:workspace.all")}
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="flex-1">
<SelectValue>
{t("history:sort.prefix")} {t(`history:sort.${sortOption}`)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="newest" data-testid="select-newest">
<div className="flex items-center gap-2">
<span className="codicon codicon-arrow-down" />
{t("history:newest")}
</div>
</SelectItem>
<SelectItem value="oldest" data-testid="select-oldest">
<div className="flex items-center gap-2">
<span className="codicon codicon-arrow-up" />
{t("history:oldest")}
</div>
</SelectItem>
<SelectItem value="mostExpensive" data-testid="select-most-expensive">
<div className="flex items-center gap-2">
<span className="codicon codicon-credit-card" />
{t("history:mostExpensive")}
</div>
</SelectItem>
<SelectItem value="mostTokens" data-testid="select-most-tokens">
<div className="flex items-center gap-2">
<span className="codicon codicon-symbol-numeric" />
{t("history:mostTokens")}
</div>
</SelectItem>
<SelectItem
value="mostRelevant"
disabled={!searchQuery}
data-testid="select-most-relevant">
<div className="flex items-center gap-2">
<span className="codicon codicon-search" />
{t("history:mostRelevant")}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Select all control in selection mode */}
@ -193,10 +213,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<TabContent className="p-0">
<Virtuoso
style={{
flexGrow: 1,
overflowY: "scroll",
}}
className="flex-1 overflow-y-scroll"
data={tasks}
data-testid="virtuoso-container"
initialTopMostItemIndex={0}
@ -205,7 +222,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
)),
}}
itemContent={(index, item) => (
itemContent={(_index, item) => (
<TaskItem
key={item.id}
item={item}
@ -215,9 +232,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
isSelected={selectedTaskIds.includes(item.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className={cn({
"border-b border-vscode-panel-border": index < tasks.length - 1,
})}
className="m-2 mr-0"
/>
)}
/>

View file

@ -4,7 +4,6 @@ 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"
@ -34,8 +33,6 @@ const TaskItem = ({
onDelete,
className,
}: TaskItemProps) => {
const { t } = useAppTranslation()
const handleClick = () => {
if (isSelectionMode && onToggleSelection) {
onToggleSelection(item.id, !isSelected)
@ -49,24 +46,13 @@ const TaskItem = ({
return (
<div
key={item.id}
data-testid={isCompact ? undefined : `task-item-${item.id}`}
data-testid={`task-item-${item.id}`}
className={cn(
"cursor-pointer",
{
// Compact variant styling
"group 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,
},
"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden hover:border-vscode-toolbar-hoverBackground/60",
className,
)}
onClick={handleClick}>
<div
className={cn("flex gap-2", {
"flex-col p-3 pt-1": isCompact,
"items-start p-3 ml-2": !isCompact,
})}>
<div className="flex gap-2 p-3">
{/* Selection checkbox - only in full variant */}
{!isCompact && isSelectionMode && (
<div
@ -84,29 +70,15 @@ const TaskItem = ({
<div className="flex-1">
{/* Header with metadata */}
<TaskItemHeader
item={item}
variant={variant}
isSelectionMode={isSelectionMode}
t={t}
onDelete={onDelete}
/>
<TaskItemHeader item={item} isSelectionMode={isSelectionMode} onDelete={onDelete} />
{/* Task content */}
<div
className={cn("overflow-hidden whitespace-pre-wrap", {
"text-vscode-foreground": isCompact,
className={cn("overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis", {
"text-base line-clamp-3": !isCompact,
"line-clamp-2": 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"}
data-testid="task-content"
{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
{item.highlight ? undefined : item.task}
</div>
@ -116,10 +88,7 @@ const TaskItem = ({
{/* Workspace info */}
{showWorkspace && item.workspace && (
<div
className={cn("flex flex-row gap-1 text-vscode-descriptionForeground text-xs", {
"mt-1": isCompact,
})}>
<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>

View file

@ -1,9 +1,8 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { Coins } from "lucide-react"
import { Coins, FileIcon } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { formatLargeNumber } from "@/utils/format"
import { cn } from "@/lib/utils"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { CopyButton } from "./CopyButton"
import { ExportButton } from "./ExportButton"
@ -14,102 +13,48 @@ export interface TaskItemFooterProps {
}
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 className="text-xs text-vscode-descriptionForeground flex justify-between items-center mt-1">
<div className="flex gap-2">
{!!(item.cacheReads || item.cacheWrites) && (
<span className="flex items-center" data-testid="cache-compact">
<i className="mr-1 codicon codicon-cloud-upload text-sm! text-vscode-descriptionForeground" />
<span className="inline-block mr-1">{formatLargeNumber(item.cacheWrites || 0)}</span>
<i className="mr-1 codicon codicon-cloud-download text-sm! text-vscode-descriptionForeground" />
<span>{formatLargeNumber(item.cacheReads || 0)}</span>
</span>
)}
{/* Full Tokens */}
{!!(item.tokensIn || item.tokensOut) && (
<span className="flex items-center gap-1">
<span data-testid="tokens-in-footer-compact"> {formatLargeNumber(item.tokensIn || 0)}</span>
<span data-testid="tokens-out-footer-compact"> {formatLargeNumber(item.tokensOut || 0)}</span>
</span>
)}
{/* Full 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>
)}
{!!item.size && (
<span className="flex items-center">
<FileIcon className="inline-block size-[1em] mr-1" />
<span data-testid="size-footer-compact">{prettyBytes(item.size)}</span>
</span>
)}
</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} />
{variant === "full" && <ExportButton itemId={item.id} />}
</div>
)}
</div>
)

View file

@ -1,40 +1,23 @@
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"
import { DeleteButton } from "./DeleteButton"
import { cn } from "@/lib/utils"
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)
}
}
const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
return (
<div className="flex justify-between items-center pb-0">
<div
className={cn("flex justify-between items-center", {
// this is to balance out the margin when we don't have a delete button
// because the delete button sorta pushes the date up due to its size
"mb-1": !onDelete,
})}>
<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)}
@ -44,27 +27,7 @@ const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, variant, isSelect
{/* Action Buttons */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-20 group-hover: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>
)}
</>
)}
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}
</div>

View file

@ -0,0 +1,87 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { BatchDeleteTaskDialog } from "../BatchDeleteTaskDialog"
import { vscode } from "@/utils/vscode"
jest.mock("@/utils/vscode")
jest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, options?: Record<string, any>) => {
const translations: Record<string, string> = {
"history:deleteTasks": "Delete Tasks",
"history:confirmDeleteTasks": `Are you sure you want to delete ${options?.count || 0} tasks?`,
"history:deleteTasksWarning": "This action cannot be undone.",
"history:cancel": "Cancel",
"history:deleteItems": `Delete ${options?.count || 0} items`,
}
return translations[key] || key
},
}),
}))
describe("BatchDeleteTaskDialog", () => {
const mockTaskIds = ["task-1", "task-2", "task-3"]
const mockOnOpenChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it("renders dialog with correct content", () => {
render(<BatchDeleteTaskDialog taskIds={mockTaskIds} open={true} onOpenChange={mockOnOpenChange} />)
expect(screen.getByText("Delete Tasks")).toBeInTheDocument()
expect(screen.getByText("Are you sure you want to delete 3 tasks?")).toBeInTheDocument()
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument()
expect(screen.getByText("Cancel")).toBeInTheDocument()
expect(screen.getByText("Delete 3 items")).toBeInTheDocument()
})
it("calls vscode.postMessage when delete is confirmed", () => {
render(<BatchDeleteTaskDialog taskIds={mockTaskIds} open={true} onOpenChange={mockOnOpenChange} />)
const deleteButton = screen.getByText("Delete 3 items")
fireEvent.click(deleteButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteMultipleTasksWithIds",
ids: mockTaskIds,
})
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("calls onOpenChange when cancel is clicked", () => {
render(<BatchDeleteTaskDialog taskIds={mockTaskIds} open={true} onOpenChange={mockOnOpenChange} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("does not call vscode.postMessage when taskIds is empty", () => {
render(<BatchDeleteTaskDialog taskIds={[]} open={true} onOpenChange={mockOnOpenChange} />)
const deleteButton = screen.getByText("Delete 0 items")
fireEvent.click(deleteButton)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("renders with correct task count in messages", () => {
const singleTaskId = ["task-1"]
render(<BatchDeleteTaskDialog taskIds={singleTaskId} open={true} onOpenChange={mockOnOpenChange} />)
expect(screen.getByText("Are you sure you want to delete 1 tasks?")).toBeInTheDocument()
expect(screen.getByText("Delete 1 items")).toBeInTheDocument()
})
it("renders trash icon in delete button", () => {
render(<BatchDeleteTaskDialog taskIds={mockTaskIds} open={true} onOpenChange={mockOnOpenChange} />)
const deleteButton = screen.getByText("Delete 3 items")
const trashIcon = deleteButton.querySelector(".codicon-trash")
expect(trashIcon).toBeInTheDocument()
})
})

View file

@ -0,0 +1,31 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { CopyButton } from "../CopyButton"
import { useClipboard } from "@/components/ui/hooks"
jest.mock("@/components/ui/hooks")
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
describe("CopyButton", () => {
const mockCopy = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
;(useClipboard as jest.Mock).mockReturnValue({
isCopied: false,
copy: mockCopy,
})
})
it("copies task content when clicked", () => {
render(<CopyButton itemTask="Test task content" />)
const copyButton = screen.getByRole("button")
fireEvent.click(copyButton)
expect(mockCopy).toHaveBeenCalledWith("Test task content")
})
})

View file

@ -0,0 +1,20 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { DeleteButton } from "../DeleteButton"
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
describe("DeleteButton", () => {
it("calls onDelete when clicked", () => {
const onDelete = jest.fn()
render(<DeleteButton itemId="test-id" onDelete={onDelete} />)
const deleteButton = screen.getByRole("button")
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith("test-id")
})
})

View file

@ -0,0 +1,143 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { DeleteTaskDialog } from "../DeleteTaskDialog"
import { vscode } from "@/utils/vscode"
jest.mock("@/utils/vscode")
jest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"history:deleteTask": "Delete Task",
"history:deleteTaskMessage": "Are you sure you want to delete this task? This action cannot be undone.",
"history:cancel": "Cancel",
"history:delete": "Delete",
}
return translations[key] || key
},
}),
}))
jest.mock("react-use", () => ({
useKeyPress: jest.fn(),
}))
import { useKeyPress } from "react-use"
const mockUseKeyPress = useKeyPress as jest.MockedFunction<typeof useKeyPress>
describe("DeleteTaskDialog", () => {
const mockTaskId = "test-task-id"
const mockOnOpenChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
mockUseKeyPress.mockReturnValue([false, null])
})
it("renders dialog with correct content", () => {
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
expect(screen.getByText("Delete Task")).toBeInTheDocument()
expect(
screen.getByText("Are you sure you want to delete this task? This action cannot be undone."),
).toBeInTheDocument()
expect(screen.getByText("Cancel")).toBeInTheDocument()
expect(screen.getByText("Delete")).toBeInTheDocument()
})
it("calls vscode.postMessage when delete is confirmed", () => {
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
const deleteButton = screen.getByText("Delete")
fireEvent.click(deleteButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: mockTaskId,
})
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("calls onOpenChange when cancel is clicked", () => {
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("does not call vscode.postMessage when taskId is empty", () => {
render(<DeleteTaskDialog taskId="" open={true} onOpenChange={mockOnOpenChange} />)
const deleteButton = screen.getByText("Delete")
fireEvent.click(deleteButton)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("handles Enter key press to delete task", () => {
// Mock Enter key being pressed
mockUseKeyPress.mockReturnValue([true, null])
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: mockTaskId,
})
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("does not delete on Enter key press when taskId is empty", () => {
// Mock Enter key being pressed
mockUseKeyPress.mockReturnValue([true, null])
render(<DeleteTaskDialog taskId="" open={true} onOpenChange={mockOnOpenChange} />)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(mockOnOpenChange).not.toHaveBeenCalled()
})
it("calls onOpenChange on escape key", () => {
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
// Simulate escape key press on the dialog content
const dialogContent = screen.getByRole("alertdialog")
fireEvent.keyDown(dialogContent, { key: "Escape" })
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
it("has correct button variants", () => {
render(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
const cancelButton = screen.getByText("Cancel")
const deleteButton = screen.getByText("Delete")
// These should have the correct styling classes based on the component
expect(cancelButton).toBeInTheDocument()
expect(deleteButton).toBeInTheDocument()
})
it("handles multiple Enter key presses correctly", () => {
// First render with Enter not pressed
const { rerender } = render(
<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />,
)
expect(vscode.postMessage).not.toHaveBeenCalled()
// Then simulate Enter key press
mockUseKeyPress.mockReturnValue([true, null])
rerender(<DeleteTaskDialog taskId={mockTaskId} open={true} onOpenChange={mockOnOpenChange} />)
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: mockTaskId,
})
})
})

View file

@ -0,0 +1,28 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { ExportButton } from "../ExportButton"
import { vscode } from "@src/utils/vscode"
jest.mock("@src/utils/vscode")
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
describe("ExportButton", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("sends export message when clicked", () => {
render(<ExportButton itemId="1" />)
const exportButton = screen.getByRole("button")
fireEvent.click(exportButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "exportTaskWithId",
text: "1",
})
})
})

View file

@ -0,0 +1,199 @@
import { render, screen } from "@testing-library/react"
import HistoryPreview from "../HistoryPreview"
import type { HistoryItem } from "@roo-code/types"
jest.mock("../useTaskSearch")
jest.mock("../TaskItem", () => {
return {
__esModule: true,
default: jest.fn(({ item, variant }) => (
<div data-testid={`task-item-${item.id}`} data-variant={variant}>
{item.task}
</div>
)),
}
})
import { useTaskSearch } from "../useTaskSearch"
import TaskItem from "../TaskItem"
const mockUseTaskSearch = useTaskSearch as jest.MockedFunction<typeof useTaskSearch>
const mockTaskItem = TaskItem as jest.MockedFunction<typeof TaskItem>
const mockTasks: HistoryItem[] = [
{
id: "task-1",
number: 1,
task: "First task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
},
{
id: "task-2",
number: 2,
task: "Second task",
ts: Date.now(),
tokensIn: 200,
tokensOut: 100,
totalCost: 0.02,
},
{
id: "task-3",
number: 3,
task: "Third task",
ts: Date.now(),
tokensIn: 150,
tokensOut: 75,
totalCost: 0.015,
},
{
id: "task-4",
number: 4,
task: "Fourth task",
ts: Date.now(),
tokensIn: 300,
tokensOut: 150,
totalCost: 0.03,
},
]
describe("HistoryPreview", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("renders nothing when no tasks are available", () => {
mockUseTaskSearch.mockReturnValue({
tasks: [],
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
const { container } = render(<HistoryPreview />)
// Should render the container but no task items
expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-3")
expect(screen.queryByTestId(/task-item-/)).not.toBeInTheDocument()
})
it("renders up to 3 tasks when tasks are available", () => {
mockUseTaskSearch.mockReturnValue({
tasks: mockTasks,
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
render(<HistoryPreview />)
// Should render only the first 3 tasks
expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
expect(screen.queryByTestId("task-item-task-4")).not.toBeInTheDocument()
})
it("renders all tasks when there are 3 or fewer", () => {
const threeTasks = mockTasks.slice(0, 3)
mockUseTaskSearch.mockReturnValue({
tasks: threeTasks,
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
render(<HistoryPreview />)
expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
})
it("renders only 1 task when there is only 1 task", () => {
const oneTask = mockTasks.slice(0, 1)
mockUseTaskSearch.mockReturnValue({
tasks: oneTask,
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
render(<HistoryPreview />)
expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument()
expect(screen.queryByTestId("task-item-task-2")).not.toBeInTheDocument()
})
it("passes correct props to TaskItem components", () => {
mockUseTaskSearch.mockReturnValue({
tasks: mockTasks.slice(0, 2),
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
render(<HistoryPreview />)
// Verify TaskItem was called with correct props
expect(mockTaskItem).toHaveBeenCalledWith(
expect.objectContaining({
item: mockTasks[0],
variant: "compact",
}),
expect.anything(),
)
expect(mockTaskItem).toHaveBeenCalledWith(
expect.objectContaining({
item: mockTasks[1],
variant: "compact",
}),
expect.anything(),
)
})
it("renders with correct container classes", () => {
mockUseTaskSearch.mockReturnValue({
tasks: mockTasks.slice(0, 1),
searchQuery: "",
setSearchQuery: jest.fn(),
sortOption: "newest",
setSortOption: jest.fn(),
lastNonRelevantSort: null,
setLastNonRelevantSort: jest.fn(),
showAllWorkspaces: false,
setShowAllWorkspaces: jest.fn(),
})
const { container } = render(<HistoryPreview />)
expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-3")
})
})

View file

@ -1,337 +1,62 @@
// cd webview-ui && npx jest src/components/history/__tests__/HistoryView.test.ts
import { render, screen, fireEvent, within, act } from "@testing-library/react"
import { render, screen, fireEvent } from "@testing-library/react"
import HistoryView from "../HistoryView"
import { useExtensionState } from "@src/context/ExtensionStateContext"
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">
{data.map((item: any, index: number) => (
<div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
),
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockTaskHistory = [
{
id: "1",
number: 0,
task: "Test task 1",
ts: new Date("2022-02-16T00:00:00").getTime(),
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "/test/workspace",
},
{
id: "2",
number: 0,
task: "Test task 2",
ts: new Date("2022-02-17T00:00:00").getTime(),
ts: Date.now() + 1000,
tokensIn: 200,
tokensOut: 100,
cacheWrites: 50,
cacheReads: 25,
totalCost: 0.003,
workspace: "/test/workspace",
},
]
describe("HistoryView", () => {
beforeAll(() => {
jest.useFakeTimers()
})
afterAll(() => {
jest.useRealTimers()
})
beforeEach(() => {
jest.clearAllMocks()
;(useExtensionState as jest.Mock).mockReturnValue({
taskHistory: mockTaskHistory,
cwd: "/test/workspace",
})
})
it("renders history items correctly", () => {
it("renders the history interface", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Check if both tasks are rendered
expect(screen.getByTestId("virtuoso-item-1")).toBeInTheDocument()
expect(screen.getByTestId("virtuoso-item-2")).toBeInTheDocument()
expect(screen.getByText("Test task 1")).toBeInTheDocument()
expect(screen.getByText("Test task 2")).toBeInTheDocument()
// Check for main UI elements
expect(screen.getByText("history:history")).toBeInTheDocument()
expect(screen.getByText("history:done")).toBeInTheDocument()
expect(screen.getByPlaceholderText("history:searchPlaceholder")).toBeInTheDocument()
})
it("handles search functionality", () => {
// Setup clipboard mock that resolves immediately
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
it("calls onDone when done button is clicked", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Get search input and radio group
const searchInput = screen.getByTestId("history-search-input")
const radioGroup = screen.getByRole("radiogroup")
const doneButton = screen.getByText("history:done")
fireEvent.click(doneButton)
// Type in search
fireEvent.input(searchInput, { target: { value: "task 1" } })
// Advance timers to process search state update
jest.advanceTimersByTime(100)
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByTestId("radio-most-relevant")
expect(mostRelevantRadio).not.toBeDisabled()
// Click the radio button
fireEvent.click(mostRelevantRadio)
// Advance timers to process radio button state update
jest.advanceTimersByTime(100)
// Verify radio button is checked
const updatedRadio = within(radioGroup).getByTestId("radio-most-relevant")
expect(updatedRadio).toBeInTheDocument()
// Verify copy the plain text content of the task when the copy button is clicked
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTestId("copy-prompt-button")
fireEvent.click(copyButton)
const taskContent = within(taskContainer).getByTestId("task-content")
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(taskContent.textContent)
})
it("handles sort options correctly", async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
const radioGroup = screen.getByRole("radiogroup")
// Test changing sort options
const oldestRadio = within(radioGroup).getByTestId("radio-oldest")
fireEvent.click(oldestRadio)
// Wait for oldest radio to be checked
const checkedOldestRadio = within(radioGroup).getByTestId("radio-oldest")
expect(checkedOldestRadio).toBeInTheDocument()
const mostExpensiveRadio = within(radioGroup).getByTestId("radio-most-expensive")
fireEvent.click(mostExpensiveRadio)
// Wait for most expensive radio to be checked
const checkedExpensiveRadio = within(radioGroup).getByTestId("radio-most-expensive")
expect(checkedExpensiveRadio).toBeInTheDocument()
})
it("handles task selection", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Click on first task
fireEvent.click(screen.getByText("Test task 1"))
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "showTaskWithId",
text: "1",
})
})
it("handles selection mode clicks", async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Go to selection mode
fireEvent.click(screen.getByTestId("toggle-selection-mode-button"))
const taskContainer = screen.getByTestId("task-item-1")
// Click anywhere in the task item
fireEvent.click(taskContainer)
// Check the box instead of sending a message to open the task
expect(within(taskContainer).getByRole("checkbox")).toBeChecked()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
describe("task deletion", () => {
it("shows confirmation dialog on regular click", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Click delete button to open confirmation dialog
const deleteButton = within(taskContainer).getByTestId("delete-task-button")
fireEvent.click(deleteButton)
// Verify dialog is shown
const dialog = screen.getByRole("alertdialog")
expect(dialog).toBeInTheDocument()
// Find and click the confirm delete button in the dialog
const confirmDeleteButton = within(dialog).getByRole("button", { name: /delete/i })
fireEvent.click(confirmDeleteButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
})
})
it("deletes immediately on shift-click without confirmation", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
// Shift-click delete button
const deleteButton = within(taskContainer).getByTestId("delete-task-button")
fireEvent.click(deleteButton, { shiftKey: true })
// Verify no dialog is shown
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument()
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "deleteTaskWithId",
text: "1",
})
})
})
it("handles task copying", async () => {
// Setup clipboard mock that resolves immediately
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId("virtuoso-item-1")
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTestId("copy-prompt-button")
// Click the copy button and wait for clipboard operation
await act(async () => {
fireEvent.click(copyButton)
// Let the clipboard Promise resolve
await Promise.resolve()
// Let React process the first state update
await Promise.resolve()
})
// Verify clipboard was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1")
// Advance timer to trigger the setTimeout for modal disappearance
act(() => {
jest.advanceTimersByTime(2000)
})
// Verify modal is gone
expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
})
it("formats dates correctly", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container and check date format
const taskContainer = screen.getByTestId("virtuoso-item-1")
// 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()
})
it("displays token counts correctly", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId("virtuoso-item-1")
// 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", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find second task container
const taskContainer = screen.getByTestId("virtuoso-item-2")
// 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", () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over second task
const taskContainer = screen.getByTestId("virtuoso-item-2")
fireEvent.mouseEnter(taskContainer)
const exportButton = within(taskContainer).getByTestId("export")
fireEvent.click(exportButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "exportTaskWithId",
text: "2",
})
expect(onDone).toHaveBeenCalled()
})
})

View file

@ -1,30 +1,22 @@
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>),
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockTask: HistoryItem = {
const mockTask = {
id: "1",
number: 1,
id: "test-task-1",
task: "Test task content",
ts: new Date("2022-02-16T00:00:00").getTime(),
task: "Test task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "test-workspace",
workspace: "/test/workspace",
}
describe("TaskItem", () => {
@ -32,86 +24,96 @@ describe("TaskItem", () => {
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()
it("renders task information", () => {
render(
<TaskItem
item={mockTask}
variant="full"
isSelectionMode={true}
isSelected={false}
onToggleSelection={mockToggleSelection}
onToggleSelection={jest.fn()}
isSelectionMode={false}
/>,
)
expect(screen.getByText("Test task")).toBeInTheDocument()
expect(screen.getByText("$0.00")).toBeInTheDocument() // Component shows $0.00 for small amounts
})
it("handles selection in selection mode", () => {
const onToggleSelection = jest.fn()
render(
<TaskItem
item={mockTask}
variant="full"
isSelected={false}
onToggleSelection={onToggleSelection}
isSelectionMode={true}
/>,
)
const checkbox = screen.getByRole("checkbox")
expect(checkbox).toBeInTheDocument()
expect(checkbox).not.toBeChecked()
fireEvent.click(checkbox)
fireEvent.click(screen.getByTestId("task-item-test-task-1"))
expect(mockToggleSelection).toHaveBeenCalledWith("test-task-1", true)
expect(vscode.postMessage).not.toHaveBeenCalled()
expect(onToggleSelection).toHaveBeenCalledWith("1", true)
})
it("shows delete button in full variant when not in selection mode", () => {
const mockOnDelete = jest.fn()
render(<TaskItem item={mockTask} variant="full" onDelete={mockOnDelete} />)
it("shows action buttons", () => {
render(
<TaskItem
item={mockTask}
variant="full"
isSelected={false}
onToggleSelection={jest.fn()}
isSelectionMode={false}
/>,
)
const deleteButton = screen.getByTestId("delete-task-button")
expect(deleteButton).toBeInTheDocument()
fireEvent.click(deleteButton)
expect(mockOnDelete).toHaveBeenCalledWith("test-task-1")
// Should show copy and export buttons
expect(screen.getByTestId("copy-prompt-button")).toBeInTheDocument()
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("displays cache information when available", () => {
const taskWithCache: HistoryItem = {
it("displays cache information when present", () => {
const mockTaskWithCache = {
...mockTask,
cacheWrites: 25,
cacheReads: 10,
cacheWrites: 5,
}
render(<TaskItem item={taskWithCache} variant="full" />)
render(
<TaskItem
item={mockTaskWithCache}
variant="full"
isSelected={false}
onToggleSelection={jest.fn()}
isSelectionMode={false}
/>,
)
expect(screen.getByTestId("cache-writes")).toHaveTextContent("25")
expect(screen.getByTestId("cache-reads")).toHaveTextContent("10")
// Should display cache information in the footer
expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
expect(screen.getByText("5")).toBeInTheDocument() // cache writes
expect(screen.getByText("10")).toBeInTheDocument() // cache reads
})
it("does not display cache information when not present", () => {
const mockTaskWithoutCache = {
...mockTask,
cacheReads: 0,
cacheWrites: 0,
}
render(
<TaskItem
item={mockTaskWithoutCache}
variant="full"
isSelected={false}
onToggleSelection={jest.fn()}
isSelectionMode={false}
/>,
)
// Cache section should not be present
expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react"
import TaskItemFooter from "../TaskItemFooter"
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockItem = {
id: "1",
number: 1,
task: "Test task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "/test/workspace",
}
describe("TaskItemFooter", () => {
it("renders token information", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)
// Check for token counts using testids since the text is split across elements
expect(screen.getByTestId("tokens-in-footer-compact")).toBeInTheDocument()
expect(screen.getByTestId("tokens-out-footer-compact")).toBeInTheDocument()
})
it("renders cost information", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)
// The component shows $0.00 for small amounts, not the exact value
expect(screen.getByText("$0.00")).toBeInTheDocument()
})
it("shows action buttons", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)
// Should show copy and export buttons
expect(screen.getByTestId("copy-prompt-button")).toBeInTheDocument()
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("renders cache information when present", () => {
const mockItemWithCache = {
...mockItem,
cacheReads: 5,
cacheWrites: 3,
}
render(<TaskItemFooter item={mockItemWithCache} variant="full" />)
// Check for cache display using testid
expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
expect(screen.getByText("3")).toBeInTheDocument() // cache writes
expect(screen.getByText("5")).toBeInTheDocument() // cache reads
})
it("does not render cache information when not present", () => {
const mockItemWithoutCache = {
...mockItem,
cacheReads: 0,
cacheWrites: 0,
}
render(<TaskItemFooter item={mockItemWithoutCache} variant="full" />)
// Cache section should not be present
expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react"
import TaskItemHeader from "../TaskItemHeader"
jest.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockItem = {
id: "1",
number: 1,
task: "Test task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "/test/workspace",
}
describe("TaskItemHeader", () => {
it("renders date information", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={jest.fn()} />)
// TaskItemHeader shows the formatted date, not the task text
expect(screen.getByText(/\w+ \d{1,2}, \d{1,2}:\d{2} \w{2}/)).toBeInTheDocument() // Date format like "JUNE 14, 10:15 AM"
})
it("shows delete button when not in selection mode", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={jest.fn()} />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
})

View file

@ -0,0 +1,285 @@
import { renderHook, act } from "@testing-library/react"
import { useTaskSearch } from "../useTaskSearch"
import type { HistoryItem } from "@roo-code/types"
jest.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: jest.fn(),
}))
jest.mock("@/utils/highlight", () => ({
highlightFzfMatch: jest.fn((text) => `<mark>${text}</mark>`),
}))
import { useExtensionState } from "@/context/ExtensionStateContext"
const mockUseExtensionState = useExtensionState as jest.MockedFunction<typeof useExtensionState>
const mockTaskHistory: HistoryItem[] = [
{
id: "task-1",
number: 1,
task: "Create a React component",
ts: new Date("2022-02-16T12:00:00").getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.01,
workspace: "/workspace/project1",
},
{
id: "task-2",
number: 2,
task: "Write unit tests",
ts: new Date("2022-02-17T12:00:00").getTime(),
tokensIn: 200,
tokensOut: 100,
totalCost: 0.02,
cacheWrites: 25,
cacheReads: 10,
workspace: "/workspace/project1",
},
{
id: "task-3",
number: 3,
task: "Fix bug in authentication",
ts: new Date("2022-02-15T12:00:00").getTime(),
tokensIn: 150,
tokensOut: 75,
totalCost: 0.05,
workspace: "/workspace/project2",
},
]
describe("useTaskSearch", () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseExtensionState.mockReturnValue({
taskHistory: mockTaskHistory,
cwd: "/workspace/project1",
} as any)
})
it("returns all tasks by default", () => {
const { result } = renderHook(() => useTaskSearch())
expect(result.current.tasks).toHaveLength(2) // Only tasks from current workspace
expect(result.current.tasks[0].id).toBe("task-2") // Newest first
expect(result.current.tasks[1].id).toBe("task-1")
})
it("filters tasks by current workspace by default", () => {
const { result } = renderHook(() => useTaskSearch())
expect(result.current.tasks).toHaveLength(2)
expect(result.current.tasks.every((task) => task.workspace === "/workspace/project1")).toBe(true)
})
it("shows all workspaces when showAllWorkspaces is true", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
})
expect(result.current.tasks).toHaveLength(3)
expect(result.current.showAllWorkspaces).toBe(true)
})
it("sorts by newest by default", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
})
expect(result.current.sortOption).toBe("newest")
expect(result.current.tasks[0].id).toBe("task-2") // Feb 17
expect(result.current.tasks[1].id).toBe("task-1") // Feb 16
expect(result.current.tasks[2].id).toBe("task-3") // Feb 15
})
it("sorts by oldest", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSortOption("oldest")
})
expect(result.current.tasks[0].id).toBe("task-3") // Feb 15
expect(result.current.tasks[1].id).toBe("task-1") // Feb 16
expect(result.current.tasks[2].id).toBe("task-2") // Feb 17
})
it("sorts by most expensive", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSortOption("mostExpensive")
})
expect(result.current.tasks[0].id).toBe("task-3") // $0.05
expect(result.current.tasks[1].id).toBe("task-2") // $0.02
expect(result.current.tasks[2].id).toBe("task-1") // $0.01
})
it("sorts by most tokens", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSortOption("mostTokens")
})
// task-2: 200 + 100 + 25 + 10 = 335 tokens
// task-3: 150 + 75 = 225 tokens
// task-1: 100 + 50 = 150 tokens
expect(result.current.tasks[0].id).toBe("task-2")
expect(result.current.tasks[1].id).toBe("task-3")
expect(result.current.tasks[2].id).toBe("task-1")
})
it("filters tasks by search query", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSearchQuery("React")
})
expect(result.current.tasks).toHaveLength(1)
expect(result.current.tasks[0].id).toBe("task-1")
expect((result.current.tasks[0] as any).highlight).toBe("<mark>Create a React component</mark>")
})
it("automatically switches to mostRelevant when searching", () => {
const { result } = renderHook(() => useTaskSearch())
// Initially lastNonRelevantSort should be "newest" (the default)
expect(result.current.lastNonRelevantSort).toBe("newest")
act(() => {
result.current.setSortOption("oldest")
})
expect(result.current.sortOption).toBe("oldest")
// Clear lastNonRelevantSort to test the auto-switch behavior
act(() => {
result.current.setLastNonRelevantSort(null)
})
act(() => {
result.current.setSearchQuery("test")
})
// The hook should automatically switch to mostRelevant when there's a search query
// and the current sort is not mostRelevant and lastNonRelevantSort is null
expect(result.current.sortOption).toBe("mostRelevant")
expect(result.current.lastNonRelevantSort).toBe("oldest")
})
it("restores previous sort when clearing search", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setSortOption("mostExpensive")
})
expect(result.current.sortOption).toBe("mostExpensive")
// Clear lastNonRelevantSort to enable the auto-switch behavior
act(() => {
result.current.setLastNonRelevantSort(null)
})
act(() => {
result.current.setSearchQuery("test")
})
expect(result.current.sortOption).toBe("mostRelevant")
expect(result.current.lastNonRelevantSort).toBe("mostExpensive")
act(() => {
result.current.setSearchQuery("")
})
expect(result.current.sortOption).toBe("mostExpensive")
expect(result.current.lastNonRelevantSort).toBe(null)
})
it("handles empty task history", () => {
mockUseExtensionState.mockReturnValue({
taskHistory: [],
cwd: "/workspace/project1",
} as any)
const { result } = renderHook(() => useTaskSearch())
expect(result.current.tasks).toHaveLength(0)
})
it("filters out tasks without timestamp or task content", () => {
const incompleteTaskHistory = [
...mockTaskHistory,
{
id: "incomplete-1",
number: 4,
task: "",
ts: Date.now(),
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
{
id: "incomplete-2",
number: 5,
task: "Valid task",
ts: 0,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
] as HistoryItem[]
mockUseExtensionState.mockReturnValue({
taskHistory: incompleteTaskHistory,
cwd: "/workspace/project1",
} as any)
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
})
// Should only include tasks with both ts and task content
expect(result.current.tasks).toHaveLength(3)
expect(result.current.tasks.every((task) => task.ts && task.task)).toBe(true)
})
it("handles search with no results", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSearchQuery("nonexistent")
})
expect(result.current.tasks).toHaveLength(0)
})
it("preserves search results order when using mostRelevant sort", () => {
const { result } = renderHook(() => useTaskSearch())
act(() => {
result.current.setShowAllWorkspaces(true)
result.current.setSearchQuery("test")
result.current.setSortOption("mostRelevant")
})
// When searching, mostRelevant should preserve fzf order
// When not searching, it should fall back to newest
expect(result.current.sortOption).toBe("mostRelevant")
})
})

View file

@ -35,5 +35,17 @@
"confirmDeleteTasks": "Estàs segur que vols eliminar {{count}} tasques?",
"deleteTasksWarning": "Les tasques eliminades no es poden recuperar. Si us plau, assegura't que vols continuar.",
"deleteItems": "Eliminar {{count}} elements",
"showAllWorkspaces": "Mostrar tasques de tots els espais de treball"
"workspace": {
"prefix": "Espai de treball:",
"current": "Actual",
"all": "Tots"
},
"sort": {
"prefix": "Ordenar:",
"newest": "Més recents",
"oldest": "Més antigues",
"mostExpensive": "Més cares",
"mostTokens": "Més tokens",
"mostRelevant": "Més rellevants"
}
}

View file

@ -35,5 +35,17 @@
"confirmDeleteTasks": "Bist du sicher, dass du {{count}} Aufgaben löschen möchtest?",
"deleteTasksWarning": "Gelöschte Aufgaben können nicht wiederhergestellt werden. Bitte vergewissere dich, dass du fortfahren möchtest.",
"deleteItems": "{{count}} Elemente löschen",
"showAllWorkspaces": "Aufgaben aus allen Arbeitsbereichen anzeigen"
"workspace": {
"prefix": "Arbeitsbereich:",
"current": "Aktuell",
"all": "Alle"
},
"sort": {
"prefix": "Sortieren:",
"newest": "Neueste",
"oldest": "Älteste",
"mostExpensive": "Teuerste",
"mostTokens": "Meiste Tokens",
"mostRelevant": "Relevanteste"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "Tasks",
"viewAll": "View All Tasks",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Cache: +{{writes}} → {{reads}}",
"apiCost": "API Cost: ${{cost}}",
"history": "History",
"exitSelectionMode": "Exit Selection Mode",
"enterSelectionMode": "Enter Selection Mode",
@ -15,9 +10,6 @@
"mostTokens": "Most Tokens",
"mostRelevant": "Most Relevant",
"deleteTaskTitle": "Delete Task (Shift + Click to skip confirmation)",
"tokensLabel": "Tokens:",
"cacheLabel": "Cache:",
"apiCostLabel": "API Cost:",
"copyPrompt": "Copy Prompt",
"exportTask": "Export Task",
"deleteTask": "Delete Task",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Are you sure you want to delete {{count}} tasks?",
"deleteTasksWarning": "Deleted tasks cannot be recovered. Please make sure you want to proceed.",
"deleteItems": "Delete {{count}} Items",
"showAllWorkspaces": "Show tasks from all workspaces"
"workspace": {
"prefix": "Workspace:",
"current": "Current",
"all": "All"
},
"sort": {
"prefix": "Sort:",
"newest": "Newest",
"oldest": "Oldest",
"mostExpensive": "Most Expensive",
"mostTokens": "Most Tokens",
"mostRelevant": "Most Relevant"
}
}

View file

@ -35,5 +35,17 @@
"confirmDeleteTasks": "¿Estás seguro de que quieres eliminar {{count}} tareas?",
"deleteTasksWarning": "Las tareas eliminadas no se pueden recuperar. Por favor, asegúrate de que quieres continuar.",
"deleteItems": "Eliminar {{count}} elementos",
"showAllWorkspaces": "Mostrar tareas de todos los espacios de trabajo"
"workspace": {
"prefix": "Espacio de trabajo:",
"current": "Actual",
"all": "Todos"
},
"sort": {
"prefix": "Ordenar:",
"newest": "Más recientes",
"oldest": "Más antiguas",
"mostExpensive": "Más costosas",
"mostTokens": "Más tokens",
"mostRelevant": "Más relevantes"
}
}

View file

@ -35,5 +35,17 @@
"confirmDeleteTasks": "Êtes-vous sûr de vouloir supprimer {{count}} tâches ?",
"deleteTasksWarning": "Les tâches supprimées ne peuvent pas être récupérées. Veuillez confirmer que vous souhaitez continuer.",
"deleteItems": "Supprimer {{count}} éléments",
"showAllWorkspaces": "Afficher les tâches de tous les espaces de travail"
"workspace": {
"prefix": "Espace de travail :",
"current": "Actuel",
"all": "Tous"
},
"sort": {
"prefix": "Trier :",
"newest": "Plus récentes",
"oldest": "Plus anciennes",
"mostExpensive": "Plus coûteuses",
"mostTokens": "Plus de tokens",
"mostRelevant": "Plus pertinentes"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "कार्य",
"viewAll": "सभी देखें",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "कैश: +{{writes}} → {{reads}}",
"apiCost": "API लागत: ${{cost}}",
"history": "इतिहास",
"exitSelectionMode": "चयन मोड से बाहर निकलें",
"enterSelectionMode": "चयन मोड में प्रवेश करें",
@ -15,9 +10,6 @@
"mostTokens": "सबसे अधिक टोकन",
"mostRelevant": "सबसे प्रासंगिक",
"deleteTaskTitle": "कार्य हटाएं (Shift + क्लिक पुष्टि छोड़ने के लिए)",
"tokensLabel": "Tokens:",
"cacheLabel": "कैश:",
"apiCostLabel": "API लागत:",
"copyPrompt": "प्रॉम्प्ट कॉपी करें",
"exportTask": "कार्य निर्यात करें",
"deleteTask": "कार्य हटाएं",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "क्या आप वाकई {{count}} कार्य हटाना चाहते हैं?",
"deleteTasksWarning": "हटाए गए कार्य पुनर्प्राप्त नहीं किए जा सकते। कृपया सुनिश्चित करें कि आप आगे बढ़ना चाहते हैं।",
"deleteItems": "{{count}} आइटम हटाएं",
"showAllWorkspaces": "सभी वर्कस्पेस से कार्य दिखाएं"
"workspace": {
"prefix": "कार्यस्थान:",
"current": "वर्तमान",
"all": "सभी"
},
"sort": {
"prefix": "क्रमबद्ध करें:",
"newest": "नवीनतम",
"oldest": "सबसे पुराना",
"mostExpensive": "सबसे महंगा",
"mostTokens": "सबसे अधिक टोकन",
"mostRelevant": "सबसे प्रासंगिक"
}
}

View file

@ -37,6 +37,17 @@
"deleteTaskFavoritedWarning": "Tugas ini telah ditandai sebagai favorit. Apakah kamu yakin ingin menghapusnya?",
"deleteTasksFavoritedWarning": "{{count}} tugas yang dipilih telah ditandai sebagai favorit. Apakah kamu yakin ingin menghapusnya?",
"deleteItems": "Hapus {{count}} Item",
"showAllWorkspaces": "Tampilkan tugas dari semua workspace",
"showFavoritesOnly": "Tampilkan hanya favorit"
"workspace": {
"prefix": "Ruang Kerja:",
"current": "Saat Ini",
"all": "Semua"
},
"sort": {
"prefix": "Urutkan:",
"newest": "Terbaru",
"oldest": "Terlama",
"mostExpensive": "Termahal",
"mostTokens": "Token Terbanyak",
"mostRelevant": "Paling Relevan"
}
}

View file

@ -1,23 +1,15 @@
{
"recentTasks": "Compiti",
"viewAll": "Vedi tutto",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Cache: +{{writes}} → {{reads}}",
"apiCost": "Costo API: ${{cost}}",
"history": "Cronologia",
"exitSelectionMode": "Esci dalla modalità selezione",
"enterSelectionMode": "Entra in modalità selezione",
"done": "Fatto",
"searchPlaceholder": "Ricerca nella cronologia...",
"searchPlaceholder": "Ricerca sfocata nella cronologia...",
"newest": "Più recenti",
"oldest": "Più vecchie",
"mostExpensive": "Più costose",
"mostTokens": "Più token",
"mostRelevant": "Più rilevanti",
"deleteTaskTitle": "Elimina attività (Shift + Clic per saltare conferma)",
"tokensLabel": "Tokens:",
"cacheLabel": "Cache:",
"apiCostLabel": "Costo API:",
"copyPrompt": "Copia prompt",
"exportTask": "Esporta attività",
"deleteTask": "Elimina attività",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Sei sicuro di voler eliminare {{count}} attività?",
"deleteTasksWarning": "Le attività eliminate non possono essere recuperate. Assicurati di voler continuare.",
"deleteItems": "Elimina {{count}} elementi",
"showAllWorkspaces": "Mostra attività da tutti gli spazi di lavoro"
"workspace": {
"prefix": "Spazio di lavoro:",
"current": "Attuale",
"all": "Tutti"
},
"sort": {
"prefix": "Ordina:",
"newest": "Più recenti",
"oldest": "Più vecchie",
"mostExpensive": "Più costose",
"mostTokens": "Più token",
"mostRelevant": "Più rilevanti"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "Recent Tasks",
"viewAll": "すべて表示",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "キャッシュ: +{{writes}} → {{reads}}",
"apiCost": "API コスト: ${{cost}}",
"history": "履歴",
"exitSelectionMode": "選択モードを終了",
"enterSelectionMode": "選択モードに入る",
@ -15,9 +10,6 @@
"mostTokens": "最多トークン",
"mostRelevant": "最も関連性の高い",
"deleteTaskTitle": "タスクを削除Shift + クリックで確認をスキップ)",
"tokensLabel": "Tokens:",
"cacheLabel": "キャッシュ:",
"apiCostLabel": "API コスト:",
"copyPrompt": "プロンプトをコピー",
"exportTask": "タスクをエクスポート",
"deleteTask": "タスクを削除",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "{{count}} 件のタスクを削除してもよろしいですか?",
"deleteTasksWarning": "削除されたタスクは復元できません。続行してもよろしいですか?",
"deleteItems": "{{count}} 項目を削除",
"showAllWorkspaces": "すべてのワークスペースのタスクを表示"
"workspace": {
"prefix": "ワークスペース:",
"current": "現在",
"all": "すべて"
},
"sort": {
"prefix": "ソート:",
"newest": "最新",
"oldest": "最古",
"mostExpensive": "最も高価",
"mostTokens": "最多トークン",
"mostRelevant": "最も関連性の高い"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "작업",
"viewAll": "모두 보기",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "캐시: +{{writes}} → {{reads}}",
"apiCost": "API 비용: ${{cost}}",
"history": "기록",
"exitSelectionMode": "선택 모드 종료",
"enterSelectionMode": "선택 모드 진입",
@ -15,9 +10,6 @@
"mostTokens": "토큰 많은순",
"mostRelevant": "관련성 높은순",
"deleteTaskTitle": "작업 삭제 (Shift + 클릭으로 확인 생략)",
"tokensLabel": "Tokens:",
"cacheLabel": "캐시:",
"apiCostLabel": "API 비용:",
"copyPrompt": "프롬프트 복사",
"exportTask": "작업 내보내기",
"deleteTask": "작업 삭제",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "{{count}}개의 작업을 삭제하시겠습니까?",
"deleteTasksWarning": "삭제된 작업은 복구할 수 없습니다. 계속 진행하시겠습니까?",
"deleteItems": "{{count}}개 항목 삭제",
"showAllWorkspaces": "모든 워크스페이스의 작업 표시"
"workspace": {
"prefix": "워크스페이스:",
"current": "현재",
"all": "모두"
},
"sort": {
"prefix": "정렬:",
"newest": "최신순",
"oldest": "오래된순",
"mostExpensive": "가장 비싼순",
"mostTokens": "토큰 많은순",
"mostRelevant": "관련성 높은순"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "Taken",
"viewAll": "Alle taken weergeven",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Cache: +{{writes}} → {{reads}}",
"apiCost": "API-kosten: ${{cost}}",
"history": "Geschiedenis",
"exitSelectionMode": "Selectiemodus verlaten",
"enterSelectionMode": "Selectiemodus starten",
@ -15,9 +10,6 @@
"mostTokens": "Meeste tokens",
"mostRelevant": "Meest relevant",
"deleteTaskTitle": "Taak verwijderen (Shift + Klik om bevestiging over te slaan)",
"tokensLabel": "Tokens:",
"cacheLabel": "Cache:",
"apiCostLabel": "API-kosten:",
"copyPrompt": "Prompt kopiëren",
"exportTask": "Taak exporteren",
"deleteTask": "Taak verwijderen",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Weet je zeker dat je {{count}} taken wilt verwijderen?",
"deleteTasksWarning": "Verwijderde taken kunnen niet worden hersteld. Zorg ervoor dat je wilt doorgaan.",
"deleteItems": "Verwijder {{count}} items",
"showAllWorkspaces": "Toon taken van alle werkruimtes"
"workspace": {
"prefix": "Werkruimte:",
"current": "Huidig",
"all": "Alle"
},
"sort": {
"prefix": "Sorteren:",
"newest": "Nieuwste",
"oldest": "Oudste",
"mostExpensive": "Duurste",
"mostTokens": "Meeste tokens",
"mostRelevant": "Meest relevant"
}
}

View file

@ -1,23 +1,15 @@
{
"recentTasks": "Zadania",
"viewAll": "Zobacz wszystkie",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Pamięć podręczna: +{{writes}} → {{reads}}",
"apiCost": "Koszt API: ${{cost}}",
"history": "Historia",
"exitSelectionMode": "Wyłącz tryb wyboru",
"enterSelectionMode": "Włącz tryb wyboru",
"done": "Gotowe",
"searchPlaceholder": "Szukaj w historii...",
"searchPlaceholder": "Rozmyte wyszukiwanie historii...",
"newest": "Najnowsze",
"oldest": "Najstarsze",
"mostExpensive": "Najdroższe",
"mostTokens": "Najwięcej tokenów",
"mostRelevant": "Najbardziej trafne",
"deleteTaskTitle": "Usuń zadanie (Shift + Klik, aby pominąć potwierdzenie)",
"tokensLabel": "Tokens:",
"cacheLabel": "Pamięć podręczna:",
"apiCostLabel": "Koszt API:",
"copyPrompt": "Kopiuj prompt",
"exportTask": "Eksportuj zadanie",
"deleteTask": "Usuń zadanie",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Czy na pewno chcesz usunąć {{count}} zadań?",
"deleteTasksWarning": "Usuniętych zadań nie można przywrócić. Upewnij się, że chcesz kontynuować.",
"deleteItems": "Usuń {{count}} elementów",
"showAllWorkspaces": "Pokaż zadania ze wszystkich przestrzeni roboczych"
"workspace": {
"prefix": "Obszar roboczy:",
"current": "Bieżący",
"all": "Wszystkie"
},
"sort": {
"prefix": "Sortuj:",
"newest": "Najnowsze",
"oldest": "Najstarsze",
"mostExpensive": "Najdroższe",
"mostTokens": "Najwięcej tokenów",
"mostRelevant": "Najbardziej trafne"
}
}

View file

@ -1,23 +1,15 @@
{
"recentTasks": "Tarefas",
"viewAll": "Ver todas",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Cache: +{{writes}} → {{reads}}",
"apiCost": "Custo da API: ${{cost}}",
"history": "Histórico",
"exitSelectionMode": "Sair do modo de seleção",
"enterSelectionMode": "Entrar no modo de seleção",
"done": "Concluído",
"searchPlaceholder": "Pesquisar no histórico...",
"searchPlaceholder": "Pesquisar histórico...",
"newest": "Mais recentes",
"oldest": "Mais antigas",
"mostExpensive": "Mais caras",
"mostTokens": "Mais tokens",
"mostRelevant": "Mais relevantes",
"deleteTaskTitle": "Excluir tarefa (Shift + Clique para pular confirmação)",
"tokensLabel": "Tokens:",
"cacheLabel": "Cache:",
"apiCostLabel": "Custo da API:",
"copyPrompt": "Copiar prompt",
"exportTask": "Exportar tarefa",
"deleteTask": "Excluir tarefa",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Tem certeza que deseja excluir {{count}} tarefas?",
"deleteTasksWarning": "As tarefas excluídas não podem ser recuperadas. Por favor, certifique-se de que deseja prosseguir.",
"deleteItems": "Excluir {{count}} itens",
"showAllWorkspaces": "Mostrar tarefas de todos os espaços de trabalho"
"workspace": {
"prefix": "Espaço de trabalho:",
"current": "Atual",
"all": "Todos"
},
"sort": {
"prefix": "Ordenar:",
"newest": "Mais recentes",
"oldest": "Mais antigas",
"mostExpensive": "Mais caras",
"mostTokens": "Mais tokens",
"mostRelevant": "Mais relevantes"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "Недавние задачи",
"viewAll": "Просмотреть все задачи",
"tokens": "Токены: ↑{{in}} ↓{{out}}",
"cache": "Кэш: +{{writes}} → {{reads}}",
"apiCost": "Стоимость API: ${{cost}}",
"history": "История",
"exitSelectionMode": "Выйти из режима выбора",
"enterSelectionMode": "Войти в режим выбора",
@ -15,9 +10,6 @@
"mostTokens": "Больше всего токенов",
"mostRelevant": "Наиболее релевантные",
"deleteTaskTitle": "Удалить задачу (Shift + клик для пропуска подтверждения)",
"tokensLabel": "Токены:",
"cacheLabel": "Кэш:",
"apiCostLabel": "Стоимость API:",
"copyPrompt": "Скопировать запрос",
"exportTask": "Экспортировать задачу",
"deleteTask": "Удалить задачу",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Вы уверены, что хотите удалить {{count}} задач?",
"deleteTasksWarning": "Удалённые задачи не могут быть восстановлены. Пожалуйста, убедитесь, что хотите продолжить.",
"deleteItems": "Удалить {{count}} элементов",
"showAllWorkspaces": "Показать задачи из всех рабочих пространств"
"workspace": {
"prefix": "Рабочая область:",
"current": "Текущая",
"all": "Все"
},
"sort": {
"prefix": "Сортировать:",
"newest": "Самые новые",
"oldest": "Самые старые",
"mostExpensive": "Самые дорогие",
"mostTokens": "Больше всего токенов",
"mostRelevant": "Наиболее релевантные"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "Görevler",
"viewAll": "Tümünü Gör",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "Önbellek: +{{writes}} → {{reads}}",
"apiCost": "API Maliyeti: ${{cost}}",
"history": "Geçmiş",
"exitSelectionMode": "Seçim Modundan Çık",
"enterSelectionMode": "Seçim Moduna Gir",
@ -15,9 +10,6 @@
"mostTokens": "En Çok Token",
"mostRelevant": "En İlgili",
"deleteTaskTitle": "Görevi Sil (Onayı atlamak için Shift + Tıkla)",
"tokensLabel": "Tokens:",
"cacheLabel": "Önbellek:",
"apiCostLabel": "API Maliyeti:",
"copyPrompt": "Promptu Kopyala",
"exportTask": "Görevi Dışa Aktar",
"deleteTask": "Görevi Sil",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "{{count}} görevi silmek istediğinizden emin misiniz?",
"deleteTasksWarning": "Silinen görevler geri alınamaz. Lütfen devam etmek istediğinizden emin olun.",
"deleteItems": "{{count}} Öğeyi Sil",
"showAllWorkspaces": "Tüm çalışma alanlarından görevleri göster"
"workspace": {
"prefix": "Çalışma Alanı:",
"current": "Mevcut",
"all": "Tümü"
},
"sort": {
"prefix": "Sırala:",
"newest": "En Yeni",
"oldest": "En Eski",
"mostExpensive": "En Pahalı",
"mostTokens": "En Çok Token",
"mostRelevant": "En İlgili"
}
}

View file

@ -1,10 +1,7 @@
{
"recentTasks": "Nhiệm vụ",
"viewAll": "Xem tất cả",
"tokens": "Token: ↑{{in}} ↓{{out}}",
"cache": "Bộ nhớ đệm: +{{writes}} → {{reads}}",
"apiCost": "Chi phí API: ${{cost}}",
"history": "Lịch sử",
"exitSelectionMode": "Thoát chế độ chọn",
"enterSelectionMode": "Vào chế độ chọn",
"done": "Hoàn thành",
"searchPlaceholder": "Tìm kiếm lịch sử...",
"newest": "Mới nhất",
@ -13,17 +10,12 @@
"mostTokens": "Nhiều token nhất",
"mostRelevant": "Liên quan nhất",
"deleteTaskTitle": "Xóa nhiệm vụ (Shift + Click để bỏ qua xác nhận)",
"tokensLabel": "Token:",
"cacheLabel": "Bộ nhớ đệm:",
"apiCostLabel": "Chi phí API:",
"copyPrompt": "Sao chép lời nhắc",
"exportTask": "Xuất nhiệm vụ",
"deleteTask": "Xóa nhiệm vụ",
"deleteTaskMessage": "Bạn có chắc chắn muốn xóa nhiệm vụ này không? Hành động này không thể hoàn tác.",
"cancel": "Hủy",
"delete": "Xóa",
"exitSelectionMode": "Thoát chế độ chọn",
"enterSelectionMode": "Vào chế độ chọn",
"exitSelection": "Thoát chọn",
"selectionMode": "Chế độ chọn",
"deselectAll": "Bỏ chọn tất cả",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "Bạn có chắc chắn muốn xóa {{count}} nhiệm vụ không?",
"deleteTasksWarning": "Các nhiệm vụ đã xóa không thể khôi phục. Vui lòng chắc chắn bạn muốn tiếp tục.",
"deleteItems": "Xóa {{count}} mục",
"showAllWorkspaces": "Hiển thị nhiệm vụ từ tất cả không gian làm việc"
"workspace": {
"prefix": "Không gian làm việc:",
"current": "Hiện tại",
"all": "Tất cả"
},
"sort": {
"prefix": "Sắp xếp:",
"newest": "Mới nhất",
"oldest": "Cũ nhất",
"mostExpensive": "Đắt nhất",
"mostTokens": "Nhiều token nhất",
"mostRelevant": "Liên quan nhất"
}
}

View file

@ -1,23 +1,15 @@
{
"recentTasks": "任务",
"viewAll": "查看全部",
"tokens": "Token用量: ↑{{in}} ↓{{out}}",
"cache": "缓存操作: +{{writes}} → {{reads}}",
"apiCost": "API费用: ${{cost}}",
"history": "历史记录",
"exitSelectionMode": "退出多选模式",
"enterSelectionMode": "进入多选模式",
"done": "完成",
"searchPlaceholder": "请输入搜索关键词",
"newest": "时间↓",
"oldest": "时间↑",
"mostExpensive": "费用",
"mostTokens": "上下文↓",
"mostRelevant": "相关性↓",
"searchPlaceholder": "模糊搜索历史记录...",
"newest": "最新",
"oldest": "最旧",
"mostExpensive": "费用最高",
"mostTokens": "最多 Token",
"mostRelevant": "相关",
"deleteTaskTitle": "删除任务Shift + 点击跳过确认)",
"tokensLabel": "Token用量:",
"cacheLabel": "缓存操作:",
"apiCostLabel": "API费用:",
"copyPrompt": "复制提示词",
"exportTask": "导出任务",
"deleteTask": "删除任务",
@ -25,7 +17,7 @@
"cancel": "取消",
"delete": "删除",
"exitSelection": "退出多选",
"selectionMode": "多选",
"selectionMode": "多选模式",
"deselectAll": "取消全选",
"selectAll": "全选",
"selectedItems": "已选 {{selected}}/{{total}} 项",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "确认删除 {{count}} 项任务?",
"deleteTasksWarning": "删除后将无法恢复,请谨慎操作。",
"deleteItems": "删除 {{count}} 项",
"showAllWorkspaces": "显示所有工作区的任务"
"workspace": {
"prefix": "工作区:",
"current": "当前",
"all": "所有"
},
"sort": {
"prefix": "排序:",
"newest": "最新",
"oldest": "最旧",
"mostExpensive": "费用最高",
"mostTokens": "最多 Token",
"mostRelevant": "最相关"
}
}

View file

@ -1,9 +1,4 @@
{
"recentTasks": "工作",
"viewAll": "檢視全部",
"tokens": "Tokens: ↑{{in}} ↓{{out}}",
"cache": "快取:+{{writes}} → {{reads}}",
"apiCost": "API 費用:${{cost}}",
"history": "歷史記錄",
"exitSelectionMode": "離開選擇模式",
"enterSelectionMode": "進入選擇模式",
@ -15,9 +10,6 @@
"mostTokens": "最多 Token",
"mostRelevant": "最相關",
"deleteTaskTitle": "刪除工作(按住 Shift 並點選可跳過確認)",
"tokensLabel": "Tokens:",
"cacheLabel": "快取:",
"apiCostLabel": "API 費用:",
"copyPrompt": "複製提示詞",
"exportTask": "匯出工作",
"deleteTask": "刪除工作",
@ -35,5 +27,17 @@
"confirmDeleteTasks": "確定要刪除 {{count}} 個工作嗎?",
"deleteTasksWarning": "已刪除的工作無法還原。請確認是否要繼續。",
"deleteItems": "刪除 {{count}} 個項目",
"showAllWorkspaces": "顯示所有工作區的工作"
"workspace": {
"prefix": "工作區:",
"current": "目前",
"all": "所有"
},
"sort": {
"prefix": "排序:",
"newest": "最新",
"oldest": "最舊",
"mostExpensive": "費用最高",
"mostTokens": "最多 Token",
"mostRelevant": "最相關"
}
}