mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Add copy prompt to history (#56)
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"displayName": "Roo Cline",
|
"displayName": "Roo Cline",
|
||||||
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
||||||
"publisher": "RooVeterinaryInc",
|
"publisher": "RooVeterinaryInc",
|
||||||
"version": "2.1.15",
|
"version": "2.1.16",
|
||||||
"icon": "assets/icons/rocket.png",
|
"icon": "assets/icons/rocket.png",
|
||||||
"galleryBanner": {
|
"galleryBanner": {
|
||||||
"color": "#617A91",
|
"color": "#617A91",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
||||||
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
||||||
|
const [showCopyModal, setShowCopyModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
|
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
|
||||||
@@ -36,6 +37,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
vscode.postMessage({ type: "deleteTaskWithId", text: id })
|
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 formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
return date
|
return date
|
||||||
@@ -103,12 +115,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
.history-item:hover {
|
.history-item:hover {
|
||||||
background-color: var(--vscode-list-hoverBackground);
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
}
|
}
|
||||||
.delete-button, .export-button {
|
.delete-button, .export-button, .copy-button {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.history-item:hover .delete-button,
|
.history-item:hover .delete-button,
|
||||||
.history-item:hover .export-button {
|
.history-item:hover .export-button,
|
||||||
|
.history-item:hover .copy-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -116,8 +129,26 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||||
color: inherit;
|
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>
|
</style>
|
||||||
|
{showCopyModal && (
|
||||||
|
<div className="copy-modal">
|
||||||
|
Prompt Copied to Clipboard
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -190,22 +221,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
||||||
{/* {presentableTasks.length === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
|
|
||||||
alignItems: "center",
|
|
||||||
fontStyle: "italic",
|
|
||||||
color: "var(--vscode-descriptionForeground)",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "0px 10px",
|
|
||||||
}}>
|
|
||||||
<span
|
|
||||||
className="codicon codicon-robot"
|
|
||||||
style={{ fontSize: "60px", marginBottom: "10px" }}></span>
|
|
||||||
<div>Start a task to see it here</div>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
style={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
@@ -247,15 +262,25 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
|||||||
}}>
|
}}>
|
||||||
{formatDate(item.ts)}
|
{formatDate(item.ts)}
|
||||||
</span>
|
</span>
|
||||||
<VSCodeButton
|
<div style={{ display: "flex", gap: "4px" }}>
|
||||||
appearance="icon"
|
<VSCodeButton
|
||||||
onClick={(e) => {
|
appearance="icon"
|
||||||
e.stopPropagation()
|
title="Copy Prompt"
|
||||||
handleDeleteHistoryItem(item.id)
|
className="copy-button"
|
||||||
}}
|
onClick={(e) => handleCopyTask(e, item.task)}>
|
||||||
className="delete-button">
|
<span className="codicon codicon-copy"></span>
|
||||||
<span className="codicon codicon-trash"></span>
|
</VSCodeButton>
|
||||||
</VSCodeButton>
|
<VSCodeButton
|
||||||
|
appearance="icon"
|
||||||
|
title="Delete Task"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteHistoryItem(item.id)
|
||||||
|
}}
|
||||||
|
className="delete-button">
|
||||||
|
<span className="codicon codicon-trash"></span>
|
||||||
|
</VSCodeButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
362
webview-ui/src/components/history/__tests__/HistoryView.test.tsx
Normal file
362
webview-ui/src/components/history/__tests__/HistoryView.test.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import HistoryView from '../HistoryView'
|
||||||
|
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
|
||||||
|
import { vscode } from '../../../utils/vscode'
|
||||||
|
import { highlight } from '../HistoryView'
|
||||||
|
import { FuseResult } from 'fuse.js'
|
||||||
|
|
||||||
|
// Mock vscode API
|
||||||
|
jest.mock('../../../utils/vscode', () => ({
|
||||||
|
vscode: {
|
||||||
|
postMessage: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface VSCodeButtonProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: (e: any) => void;
|
||||||
|
appearance?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VSCodeTextFieldProps {
|
||||||
|
value?: string;
|
||||||
|
onInput?: (e: { target: { value: string } }) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VSCodeRadioGroupProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: { target: { value: string } }) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VSCodeRadioProps {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock VSCode components
|
||||||
|
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
|
||||||
|
VSCodeButton: function MockVSCodeButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
appearance,
|
||||||
|
className
|
||||||
|
}: VSCodeButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
data-appearance={appearance}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
VSCodeTextField: function MockVSCodeTextField({
|
||||||
|
value,
|
||||||
|
onInput,
|
||||||
|
placeholder,
|
||||||
|
style
|
||||||
|
}: VSCodeTextFieldProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
VSCodeRadioGroup: function MockVSCodeRadioGroup({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
style
|
||||||
|
}: VSCodeRadioGroupProps) {
|
||||||
|
return (
|
||||||
|
<div style={style} role="radiogroup" data-current-value={value}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
VSCodeRadio: function MockVSCodeRadio({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
style
|
||||||
|
}: VSCodeRadioProps) {
|
||||||
|
return (
|
||||||
|
<label style={style}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={`radio-${value}`}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock window.navigator.clipboard
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: {
|
||||||
|
writeText: jest.fn(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock window.postMessage to trigger state hydration
|
||||||
|
const mockPostMessage = (state: any) => {
|
||||||
|
window.postMessage({
|
||||||
|
type: 'state',
|
||||||
|
state: {
|
||||||
|
version: '1.0.0',
|
||||||
|
taskHistory: [],
|
||||||
|
...state
|
||||||
|
}
|
||||||
|
}, '*')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HistoryView', () => {
|
||||||
|
const mockOnDone = jest.fn()
|
||||||
|
const sampleHistory = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
task: 'First task',
|
||||||
|
ts: Date.now() - 3000,
|
||||||
|
tokensIn: 100,
|
||||||
|
tokensOut: 50,
|
||||||
|
totalCost: 0.002
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
task: 'Second task',
|
||||||
|
ts: Date.now() - 2000,
|
||||||
|
tokensIn: 200,
|
||||||
|
tokensOut: 100,
|
||||||
|
totalCost: 0.004
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
task: 'Third task',
|
||||||
|
ts: Date.now() - 1000,
|
||||||
|
tokensIn: 300,
|
||||||
|
tokensOut: 150,
|
||||||
|
totalCost: 0.006
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders history items in correct order', () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
const historyItems = screen.getAllByText(/task/i)
|
||||||
|
expect(historyItems).toHaveLength(3)
|
||||||
|
expect(historyItems[0]).toHaveTextContent('Third task')
|
||||||
|
expect(historyItems[1]).toHaveTextContent('Second task')
|
||||||
|
expect(historyItems[2]).toHaveTextContent('First task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles sorting by different criteria', async () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
// Test oldest sort
|
||||||
|
const oldestRadio = screen.getByTestId('radio-oldest')
|
||||||
|
fireEvent.click(oldestRadio)
|
||||||
|
|
||||||
|
let historyItems = screen.getAllByText(/task/i)
|
||||||
|
expect(historyItems[0]).toHaveTextContent('First task')
|
||||||
|
expect(historyItems[2]).toHaveTextContent('Third task')
|
||||||
|
|
||||||
|
// Test most expensive sort
|
||||||
|
const expensiveRadio = screen.getByTestId('radio-mostExpensive')
|
||||||
|
fireEvent.click(expensiveRadio)
|
||||||
|
|
||||||
|
historyItems = screen.getAllByText(/task/i)
|
||||||
|
expect(historyItems[0]).toHaveTextContent('Third task')
|
||||||
|
expect(historyItems[2]).toHaveTextContent('First task')
|
||||||
|
|
||||||
|
// Test most tokens sort
|
||||||
|
const tokensRadio = screen.getByTestId('radio-mostTokens')
|
||||||
|
fireEvent.click(tokensRadio)
|
||||||
|
|
||||||
|
historyItems = screen.getAllByText(/task/i)
|
||||||
|
expect(historyItems[0]).toHaveTextContent('Third task')
|
||||||
|
expect(historyItems[2]).toHaveTextContent('First task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles search functionality and auto-switches to most relevant sort', async () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'First' } })
|
||||||
|
|
||||||
|
const historyItems = screen.getAllByText(/task/i)
|
||||||
|
expect(historyItems).toHaveLength(1)
|
||||||
|
expect(historyItems[0]).toHaveTextContent('First task')
|
||||||
|
|
||||||
|
// Verify sort switched to Most Relevant
|
||||||
|
const radioGroup = screen.getByRole('radiogroup')
|
||||||
|
expect(radioGroup.getAttribute('data-current-value')).toBe('mostRelevant')
|
||||||
|
|
||||||
|
// Clear search and verify sort reverts
|
||||||
|
fireEvent.change(searchInput, { target: { value: '' } })
|
||||||
|
expect(radioGroup.getAttribute('data-current-value')).toBe('newest')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles copy functionality and shows/hides modal', async () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
const copyButtons = screen.getAllByRole('button', { hidden: true })
|
||||||
|
.filter(button => button.className.includes('copy-button'))
|
||||||
|
|
||||||
|
fireEvent.click(copyButtons[0])
|
||||||
|
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Third task')
|
||||||
|
|
||||||
|
// Verify modal appears
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Prompt Copied to Clipboard')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify modal disappears
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
|
||||||
|
}, { timeout: 2500 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles delete functionality', () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { hidden: true })
|
||||||
|
.filter(button => button.className.includes('delete-button'))
|
||||||
|
|
||||||
|
fireEvent.click(deleteButtons[0])
|
||||||
|
|
||||||
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'deleteTaskWithId',
|
||||||
|
text: '3'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles export functionality', () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
mockPostMessage({ taskHistory: sampleHistory })
|
||||||
|
|
||||||
|
const exportButtons = screen.getAllByRole('button', { hidden: true })
|
||||||
|
.filter(button => button.className.includes('export-button'))
|
||||||
|
|
||||||
|
fireEvent.click(exportButtons[0])
|
||||||
|
|
||||||
|
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||||
|
type: 'exportTaskWithId',
|
||||||
|
text: '3'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onDone when Done button is clicked', () => {
|
||||||
|
render(
|
||||||
|
<ExtensionStateContextProvider>
|
||||||
|
<HistoryView onDone={mockOnDone} />
|
||||||
|
</ExtensionStateContextProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
const doneButton = screen.getByText('Done')
|
||||||
|
fireEvent.click(doneButton)
|
||||||
|
|
||||||
|
expect(mockOnDone).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('highlight function', () => {
|
||||||
|
it('correctly highlights search matches', () => {
|
||||||
|
const testData = [{
|
||||||
|
item: { text: 'Hello world' },
|
||||||
|
matches: [{ key: 'text', value: 'Hello world', indices: [[0, 4]] }],
|
||||||
|
refIndex: 0
|
||||||
|
}] as FuseResult<any>[]
|
||||||
|
|
||||||
|
const result = highlight(testData)
|
||||||
|
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span> world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple matches', () => {
|
||||||
|
const testData = [{
|
||||||
|
item: { text: 'Hello world Hello' },
|
||||||
|
matches: [{
|
||||||
|
key: 'text',
|
||||||
|
value: 'Hello world Hello',
|
||||||
|
indices: [[0, 4], [11, 15]]
|
||||||
|
}],
|
||||||
|
refIndex: 0
|
||||||
|
}] as FuseResult<any>[]
|
||||||
|
|
||||||
|
const result = highlight(testData)
|
||||||
|
expect(result[0].text).toBe(
|
||||||
|
'<span class="history-item-highlight">Hello</span> world ' +
|
||||||
|
'<span class="history-item-highlight">Hello</span>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles overlapping matches', () => {
|
||||||
|
const testData = [{
|
||||||
|
item: { text: 'Hello' },
|
||||||
|
matches: [{
|
||||||
|
key: 'text',
|
||||||
|
value: 'Hello',
|
||||||
|
indices: [[0, 2], [1, 4]]
|
||||||
|
}],
|
||||||
|
refIndex: 0
|
||||||
|
}] as FuseResult<any>[]
|
||||||
|
|
||||||
|
const result = highlight(testData)
|
||||||
|
expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user