Kaynağa Gözat

Add copy prompt to history (#56)

ColemanRoo 1 yıl önce
ebeveyn
işleme
1d47fa67c9

+ 1 - 1
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",

+ 52 - 27
webview-ui/src/components/history/HistoryView.tsx

@@ -17,6 +17,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	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) {
@@ -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;
+					}
 				`}
 			</style>
+			{showCopyModal && (
+				<div className="copy-modal">
+					Prompt Copied to Clipboard
+				</div>
+			)}
 			<div
 				style={{
 					position: "fixed",
@@ -190,22 +221,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 					</div>
 				</div>
 				<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
 						style={{
 							flexGrow: 1,
@@ -247,15 +262,25 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 											}}>
 											{formatDate(item.ts)}
 										</span>
-										<VSCodeButton
-											appearance="icon"
-											onClick={(e) => {
-												e.stopPropagation()
-												handleDeleteHistoryItem(item.id)
-											}}
-											className="delete-button">
-											<span className="codicon codicon-trash"></span>
-										</VSCodeButton>
+										<div style={{ display: "flex", gap: "4px" }}>
+											<VSCodeButton
+												appearance="icon"
+												title="Copy Prompt"
+												className="copy-button"
+												onClick={(e) => handleCopyTask(e, item.task)}>
+												<span className="codicon codicon-copy"></span>
+											</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
 										style={{

+ 362 - 0
webview-ui/src/components/history/__tests__/HistoryView.test.tsx

@@ -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>')
+    })
+  })
+})