Add copy prompt to history (#56)

This commit is contained in:
ColemanRoo
2024-12-09 13:25:50 -08:00
committed by GitHub
parent da31a23499
commit 1d47fa67c9
3 changed files with 415 additions and 28 deletions

View File

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

View File

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

View 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>')
})
})
})