Files
Roo-Code/webview-ui/src/components/history/HistoryView.tsx
2024-12-15 23:48:13 -05:00

575 lines
16 KiB
TypeScript

import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import React, { memo, useMemo, useState, useEffect } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { formatLargeNumber } from "../../utils/format"
type HistoryViewProps = {
onDone: () => void
}
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
const HistoryView = ({ onDone }: HistoryViewProps) => {
const { taskHistory } = useExtensionState()
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
const [showCopyModal, setShowCopyModal] = useState(false)
useEffect(() => {
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
setLastNonRelevantSort(sortOption)
setSortOption("mostRelevant")
} else if (!searchQuery && sortOption === "mostRelevant" && lastNonRelevantSort) {
setSortOption(lastNonRelevantSort)
setLastNonRelevantSort(null)
}
}, [searchQuery, sortOption, lastNonRelevantSort])
const handleHistorySelect = (id: string) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
const handleDeleteHistoryItem = (id: string) => {
vscode.postMessage({ type: "deleteTaskWithId", text: id })
}
const handleCopyTask = async (e: React.MouseEvent, task: string) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(task)
setShowCopyModal(true)
setTimeout(() => setShowCopyModal(false), 2000)
} catch (error) {
console.error('Failed to copy to clipboard:', error)
}
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
?.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}
const presentableTasks = useMemo(() => {
return taskHistory.filter((item) => item.ts && item.task)
}, [taskHistory])
const fuse = useMemo(() => {
return new Fuse(presentableTasks, {
keys: ["task"],
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [presentableTasks])
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
// First apply search if needed
const searchResults = searchQuery ? results : presentableTasks;
// Then sort the results
return [...searchResults].sort((a, b) => {
switch (sortOption) {
case "oldest":
return (a.ts || 0) - (b.ts || 0);
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0);
case "mostTokens":
const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
return bTokens - aTokens;
case "mostRelevant":
// Keep fuse order if searching, otherwise sort by newest
return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
case "newest":
default:
return (b.ts || 0) - (a.ts || 0);
}
});
}, [presentableTasks, searchQuery, fuse, sortOption])
return (
<>
<style>
{`
.history-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.delete-button, .export-button, .copy-button {
opacity: 0;
pointer-events: none;
}
.history-item:hover .delete-button,
.history-item:hover .export-button,
.history-item:hover .copy-button {
opacity: 1;
pointer-events: auto;
}
.history-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
.copy-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--vscode-notifications-background);
color: var(--vscode-notifications-foreground);
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
transition: opacity 0.2s ease-in-out;
}
`}
</style>
{showCopyModal && (
<div className="copy-modal">
Prompt Copied to Clipboard
</div>
)}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 10px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ padding: "5px 17px 6px 17px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<VSCodeTextField
style={{ width: "100%" }}
placeholder="Fuzzy search history..."
value={searchQuery}
onInput={(e) => {
const newValue = (e.target as HTMLInputElement)?.value
setSearchQuery(newValue)
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
setLastNonRelevantSort(sortOption)
setSortOption("mostRelevant")
}
}}>
<div
slot="start"
className="codicon codicon-search"
style={{ fontSize: 13, marginTop: 2.5, opacity: 0.8 }}></div>
{searchQuery && (
<div
className="input-icon-button codicon codicon-close"
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}
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
<VSCodeRadio value="newest">Newest</VSCodeRadio>
<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
<VSCodeRadio
value="mostRelevant"
disabled={!searchQuery}
style={{ opacity: searchQuery ? 1 : 0.5 }}>
Most Relevant
</VSCodeRadio>
</VSCodeRadioGroup>
</div>
</div>
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
<Virtuoso
style={{
flexGrow: 1,
overflowY: "scroll",
}}
data={taskHistorySearchResults}
data-testid="virtuoso-container"
components={{
List: React.forwardRef((props, ref) => (
<div {...props} ref={ref} data-testid="virtuoso-item-list" />
))
}}
itemContent={(index, item) => (
<div
key={item.id}
data-testid={`task-item-${item.id}`}
className="history-item"
style={{
cursor: "pointer",
borderBottom:
index < taskHistory.length - 1
? "1px solid var(--vscode-panel-border)"
: "none",
}}
onClick={() => handleHistorySelect(item.id)}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
padding: "12px 20px",
position: "relative",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
{formatDate(item.ts)}
</span>
<div style={{ display: "flex", gap: "4px" }}>
<button
title="Copy Prompt"
className="copy-button"
data-appearance="icon"
onClick={(e) => handleCopyTask(e, item.task)}>
<span className="codicon codicon-copy"></span>
</button>
<button
title="Delete Task"
className="delete-button"
data-appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}>
<span className="codicon codicon-trash"></span>
</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",
}}
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)",
}}>
Tokens:
</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 && <ExportButton itemId={item.id} />}
</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)",
}}>
Cache:
</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)",
}}>
API Cost:
</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
${item.totalCost?.toFixed(4)}
</span>
</div>
<ExportButton itemId={item.id} />
</div>
)}
</div>
</div>
</div>
)}
/>
</div>
</div>
</>
)
}
const ExportButton = ({ itemId }: { itemId: string }) => (
<VSCodeButton
className="export-button"
appearance="icon"
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
</VSCodeButton>
)
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
export const highlight = (
fuseSearchResult: FuseResult<any>[],
highlightClassName: string = "history-item-highlight",
) => {
const set = (obj: Record<string, any>, path: string, value: any) => {
const pathValue = path.split(".")
let i: number
for (i = 0; i < pathValue.length - 1; i++) {
if (pathValue[i] === "__proto__" || pathValue[i] === "constructor") return
obj = obj[pathValue[i]] as Record<string, any>
}
if (pathValue[i] !== "__proto__" && pathValue[i] !== "constructor") {
obj[pathValue[i]] = value
}
}
// Function to merge overlapping regions
const mergeRegions = (regions: [number, number][]): [number, number][] => {
if (regions.length === 0) return regions
// Sort regions by start index
regions.sort((a, b) => a[0] - b[0])
const merged: [number, number][] = [regions[0]]
for (let i = 1; i < regions.length; i++) {
const last = merged[merged.length - 1]
const current = regions[i]
if (current[0] <= last[1] + 1) {
// Overlapping or adjacent regions
last[1] = Math.max(last[1], current[1])
} else {
merged.push(current)
}
}
return merged
}
const generateHighlightedText = (inputText: string, regions: [number, number][] = []) => {
if (regions.length === 0) {
return inputText
}
// Sort and merge overlapping regions
const mergedRegions = mergeRegions(regions)
// Convert regions to a list of parts with their highlight status
const parts: { text: string; highlight: boolean }[] = []
let lastIndex = 0
mergedRegions.forEach(([start, end]) => {
// Add non-highlighted text before this region
if (start > lastIndex) {
parts.push({
text: inputText.substring(lastIndex, start),
highlight: false
})
}
// Add highlighted text
parts.push({
text: inputText.substring(start, end + 1),
highlight: true
})
lastIndex = end + 1
})
// Add any remaining text
if (lastIndex < inputText.length) {
parts.push({
text: inputText.substring(lastIndex),
highlight: false
})
}
// Build final string
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('')
}
return fuseSearchResult
.filter(({ matches }) => matches && matches.length)
.map(({ item, matches }) => {
const highlightedItem = { ...item }
matches?.forEach((match) => {
if (match.key && typeof match.value === "string" && match.indices) {
// Merge overlapping regions before generating highlighted text
const mergedIndices = mergeRegions([...match.indices])
set(highlightedItem, match.key, generateHighlightedText(match.value, mergedIndices))
}
})
return highlightedItem
})
}
export default memo(HistoryView)