From 1d47fa67c908bb8a1200ec8817d789d4d54974fc Mon Sep 17 00:00:00 2001 From: ColemanRoo <117104599+ColemanRoo@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:25:50 -0800 Subject: [PATCH] Add copy prompt to history (#56) --- package.json | 2 +- .../src/components/history/HistoryView.tsx | 79 ++-- .../history/__tests__/HistoryView.test.tsx | 362 ++++++++++++++++++ 3 files changed, 415 insertions(+), 28 deletions(-) create mode 100644 webview-ui/src/components/history/__tests__/HistoryView.test.tsx diff --git a/package.json b/package.json index 4692459..6260d48 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Roo Cline", "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.", "publisher": "RooVeterinaryInc", - "version": "2.1.15", + "version": "2.1.16", "icon": "assets/icons/rocket.png", "galleryBanner": { "color": "#617A91", diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 14b97c6..76a0e19 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -17,6 +17,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [searchQuery, setSearchQuery] = useState("") const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") + const [showCopyModal, setShowCopyModal] = useState(false) useEffect(() => { if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { @@ -36,6 +37,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { 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 @@ -103,12 +115,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { .history-item:hover { background-color: var(--vscode-list-hoverBackground); } - .delete-button, .export-button { + .delete-button, .export-button, .copy-button { opacity: 0; pointer-events: none; } .history-item:hover .delete-button, - .history-item:hover .export-button { + .history-item:hover .export-button, + .history-item:hover .copy-button { opacity: 1; pointer-events: auto; } @@ -116,8 +129,26 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { 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; + } `} + {showCopyModal && ( +
+ Prompt Copied to Clipboard +
+ )}
{
- {/* {presentableTasks.length === 0 && ( -
- -
Start a task to see it here
-
- )} */} { }}> {formatDate(item.ts)} - { - e.stopPropagation() - handleDeleteHistoryItem(item.id) - }} - className="delete-button"> - - +
+ handleCopyTask(e, item.task)}> + + + { + e.stopPropagation() + handleDeleteHistoryItem(item.id) + }} + className="delete-button"> + + +
({ + 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 ( + + ) + }, + VSCodeTextField: function MockVSCodeTextField({ + value, + onInput, + placeholder, + style + }: VSCodeTextFieldProps) { + return ( + onInput?.({ target: { value: e.target.value } })} + placeholder={placeholder} + style={style} + /> + ) + }, + VSCodeRadioGroup: function MockVSCodeRadioGroup({ + children, + value, + onChange, + style + }: VSCodeRadioGroupProps) { + return ( +
+ {children} +
+ ) + }, + VSCodeRadio: function MockVSCodeRadio({ + value, + children, + disabled, + style + }: VSCodeRadioProps) { + return ( + + ) + } +})) + +// 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + + 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[] + + const result = highlight(testData) + expect(result[0].text).toBe('Hello 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[] + + const result = highlight(testData) + expect(result[0].text).toBe( + 'Hello world ' + + 'Hello' + ) + }) + + it('handles overlapping matches', () => { + const testData = [{ + item: { text: 'Hello' }, + matches: [{ + key: 'text', + value: 'Hello', + indices: [[0, 2], [1, 4]] + }], + refIndex: 0 + }] as FuseResult[] + + const result = highlight(testData) + expect(result[0].text).toBe('Hello') + }) + }) +})