forked from bots-garden/ori
Add copy-paste functionality for code blocks and completion widgets
Implements ticket #0021 with the following features: - Created reusable CopyButton component with visual feedback - Clipboard icon that changes to checkmark on success - 2-second confirmation before reverting - Fallback for older browsers using execCommand - Added copy buttons to all code blocks in markdown - Positioned in top-right corner (standard pattern) - Extracts raw code without syntax highlighting markup - Added copy buttons to completion widgets: - Agent responses (full message) - Thought bubbles (both live and collapsed) - Tool call outputs (including diffs) - Full test coverage for CopyButton component - All existing tests pass (129/129) - CSS styling with hover/active/copied states - Accessibility: ARIA labels, keyboard support Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
7895c1d parent: f5c963a added
.tickets/issues/0021-copy-paste-completions-and-code-blocks.yaml +187 -0 | new file mode 100644 | ||
| @@ -0,0 +1,187 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 21 | |
| 3 | +title: Add copy-paste functionality for completion widgets and code blocks | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-18T00:00:00.000Z | |
| 9 | +updatedAt: 2026-09-18T00:00:00.000Z | |
| 10 | +labels: | |
| 11 | + - feature | |
| 12 | + - enhancement | |
| 13 | + - ui | |
| 14 | + - usability | |
| 15 | +body: | | |
| 16 | + Add the ability to copy-paste each generated completion widget and code portions/blocks individually. | |
| 17 | + | |
| 18 | + ## Problem Statement | |
| 19 | + | |
| 20 | + Users need to quickly copy agent responses, code snippets, and individual completion widgets without selecting text manually. This is a common UX pattern in modern AI chat interfaces. | |
| 21 | + | |
| 22 | + ## Requirements | |
| 23 | + | |
| 24 | + ### Completion Widgets | |
| 25 | + - Each completion widget (thought, tool call result, plan, etc.) should have a copy button | |
| 26 | + - Copy button appears on hover or always visible (design decision) | |
| 27 | + - Clicking copies the entire widget content to clipboard | |
| 28 | + - Visual feedback when copied (checkmark, tooltip, color change) | |
| 29 | + - Preserve formatting when copying (plain text or markdown) | |
| 30 | + | |
| 31 | + ### Code Blocks | |
| 32 | + - Every code block should have a dedicated copy button | |
| 33 | + - Button positioned in top-right corner of code block (standard pattern) | |
| 34 | + - Copy raw code without syntax highlighting markup | |
| 35 | + - Support for inline code spans (optional - may be too small for button) | |
| 36 | + - Language label displayed alongside copy button | |
| 37 | + | |
| 38 | + ### Multi-line Text Blocks | |
| 39 | + - Bash command outputs | |
| 40 | + - File content previews | |
| 41 | + - Error messages | |
| 42 | + - Log outputs | |
| 43 | + - Any multi-line formatted text | |
| 44 | + | |
| 45 | + ## User Experience | |
| 46 | + | |
| 47 | + ### Visual Design | |
| 48 | + - **Copy button icon**: clipboard icon or "Copy" text | |
| 49 | + - **Position**: Top-right corner of widget/block (consistent placement) | |
| 50 | + - **Visibility**: | |
| 51 | + - Always visible (simpler) | |
| 52 | + - OR show on hover (cleaner UI) | |
| 53 | + - **Feedback states**: | |
| 54 | + - Default: clipboard icon | |
| 55 | + - Hover: subtle highlight | |
| 56 | + - Click: changes to checkmark icon | |
| 57 | + - After 2s: reverts to clipboard icon | |
| 58 | + - **Tooltip**: "Copy" (before), "Copied!" (after) | |
| 59 | + | |
| 60 | + ### Interaction Flow | |
| 61 | + ``` | |
| 62 | + User hovers over code block | |
| 63 | + → Copy button appears/highlights | |
| 64 | + → User clicks copy button | |
| 65 | + → Content copied to clipboard | |
| 66 | + → Button shows checkmark + "Copied!" tooltip | |
| 67 | + → After 2 seconds, button reverts to default | |
| 68 | + ``` | |
| 69 | + | |
| 70 | + ## Implementation Considerations | |
| 71 | + | |
| 72 | + ### Frontend (React) | |
| 73 | + - Use Clipboard API (`navigator.clipboard.writeText()`) | |
| 74 | + - Fallback for older browsers (`document.execCommand('copy')`) | |
| 75 | + - Component: `<CopyButton content={text} />` | |
| 76 | + - State management for "copied" feedback | |
| 77 | + - Debounce multiple rapid clicks | |
| 78 | + | |
| 79 | + ### Code Block Integration | |
| 80 | + - Monaco editor code blocks: extract raw text | |
| 81 | + - Markdown code blocks: access pre-rendered content | |
| 82 | + - Syntax highlighted blocks: strip HTML/CSS, keep plain text | |
| 83 | + | |
| 84 | + ### Content Processing | |
| 85 | + - **Code blocks**: Copy raw code, preserve indentation | |
| 86 | + - **Thought widgets**: Copy plain text or markdown? | |
| 87 | + - **Tool calls**: Copy JSON formatted or pretty-printed? | |
| 88 | + - **Structured output**: Preserve structure (JSON, YAML, etc.) | |
| 89 | + - **Tables**: Copy as markdown table or TSV/CSV? | |
| 90 | + | |
| 91 | + ### Security & Privacy | |
| 92 | + - Only copy visible content (no hidden data) | |
| 93 | + - Sanitize content before copying (no script injection) | |
| 94 | + - Respect clipboard permissions (prompt if needed) | |
| 95 | + - No automatic clipboard access (user-initiated only) | |
| 96 | + | |
| 97 | + ## Widget Types to Support | |
| 98 | + | |
| 99 | + | Widget Type | Copy Format | Priority | | |
| 100 | + |------------|-------------|----------| | |
| 101 | + | Code blocks | Raw code | High | | |
| 102 | + | Thought bubbles | Plain text | High | | |
| 103 | + | Tool call output | Formatted text | High | | |
| 104 | + | File previews | Full content | Medium | | |
| 105 | + | Error messages | Plain text | Medium | | |
| 106 | + | Bash commands | Command only | Medium | | |
| 107 | + | Bash output | Output text | Medium | | |
| 108 | + | Plans | Markdown | Low | | |
| 109 | + | JSON responses | Formatted JSON | Low | | |
| 110 | + | Tables | Markdown table | Low | | |
| 111 | + | |
| 112 | + ## Example UI Mock | |
| 113 | + | |
| 114 | + ``` | |
| 115 | + ┌─────────────────────────────────────────────┐ | |
| 116 | + │ Thought: Analyzing the codebase... [📋] │ | |
| 117 | + │ │ | |
| 118 | + │ I'll search for the authentication logic... │ | |
| 119 | + └─────────────────────────────────────────────┘ | |
| 120 | + | |
| 121 | + ┌─────────────────────────────────────────────┐ | |
| 122 | + │ JavaScript [📋] │ | |
| 123 | + ├─────────────────────────────────────────────┤ | |
| 124 | + │ function authenticate(user, pass) { │ | |
| 125 | + │ return bcrypt.compare(pass, user.hash); │ | |
| 126 | + │ } │ | |
| 127 | + └─────────────────────────────────────────────┘ | |
| 128 | + | |
| 129 | + ┌─────────────────────────────────────────────┐ | |
| 130 | + │ Bash Output [✓] │ | |
| 131 | + ├─────────────────────────────────────────────┤ | |
| 132 | + │ Tests passed: 42 │ | |
| 133 | + │ Coverage: 87% │ | |
| 134 | + └─────────────────────────────────────────────┘ | |
| 135 | + ``` | |
| 136 | + | |
| 137 | + ## Accessibility | |
| 138 | + | |
| 139 | + - Button has proper ARIA label: `aria-label="Copy code"` | |
| 140 | + - Keyboard accessible: Tab to button, Enter/Space to activate | |
| 141 | + - Screen reader announces "Copied to clipboard" | |
| 142 | + - Focus visible indicator on keyboard navigation | |
| 143 | + - Sufficient color contrast for button (WCAG AA) | |
| 144 | + | |
| 145 | + ## Testing Considerations | |
| 146 | + | |
| 147 | + - Test clipboard API support detection | |
| 148 | + - Test fallback mechanism | |
| 149 | + - Test with different content types (code, JSON, markdown) | |
| 150 | + - Test with large content (>1MB) | |
| 151 | + - Test rapid successive clicks | |
| 152 | + - Test keyboard navigation | |
| 153 | + - Cross-browser testing (Chrome, Firefox, Safari, Edge) | |
| 154 | + - Mobile touch interaction testing | |
| 155 | + | |
| 156 | + ## Related Features | |
| 157 | + | |
| 158 | + - Could add "Copy all" button for entire conversation | |
| 159 | + - Export conversation as markdown with all code blocks | |
| 160 | + - Share snippet functionality (copy + generate shareable link) | |
| 161 | + - Syntax highlighting preservation option | |
| 162 | + | |
| 163 | + ## Open Questions | |
| 164 | + | |
| 165 | + - Should copy button be always visible or show on hover? | |
| 166 | + - Should we support "copy as markdown" vs "copy as plain text" toggle? | |
| 167 | + - Should inline code (`inline`) have copy buttons or only blocks? | |
| 168 | + - Maximum content size for clipboard operations? | |
| 169 | + - Should we track copy events for analytics/UX improvements? | |
| 170 | + - Mobile long-press behavior (native context menu vs custom)? | |
| 171 | + | |
| 172 | + ## Similar Implementations | |
| 173 | + | |
| 174 | + - GitHub code blocks (hover, top-right corner) | |
| 175 | + - ChatGPT code blocks (always visible, next to language label) | |
| 176 | + - VS Code (inline copy button on hover) | |
| 177 | + - Stack Overflow (copy button on code blocks) | |
| 178 | + - Claude.ai web interface (copy button on responses) | |
| 179 | + | |
| 180 | + ## Priority | |
| 181 | + | |
| 182 | + High - Essential usability feature for developer workflows | |
| 183 | + | |
| 184 | + ## Related Issues | |
| 185 | + | |
| 186 | + - Could integrate with Monaco editor's native copy functionality | |
| 187 | + - Related to file tree operations (copy file path, copy file content) | |
| new file mode 100644 | |||
| @@ -0,0 +1,187 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 21 | ||
| 3 | +title: Add copy-paste functionality for completion widgets and code blocks | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-18T00:00:00.000Z | ||
| 9 | +updatedAt: 2026-09-18T00:00:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - feature | ||
| 12 | + - enhancement | ||
| 13 | + - ui | ||
| 14 | + - usability | ||
| 15 | +body: | | ||
| 16 | + Add the ability to copy-paste each generated completion widget and code portions/blocks individually. | ||
| 17 | + | ||
| 18 | + ## Problem Statement | ||
| 19 | + | ||
| 20 | + Users need to quickly copy agent responses, code snippets, and individual completion widgets without selecting text manually. This is a common UX pattern in modern AI chat interfaces. | ||
| 21 | + | ||
| 22 | + ## Requirements | ||
| 23 | + | ||
| 24 | + ### Completion Widgets | ||
| 25 | + - Each completion widget (thought, tool call result, plan, etc.) should have a copy button | ||
| 26 | + - Copy button appears on hover or always visible (design decision) | ||
| 27 | + - Clicking copies the entire widget content to clipboard | ||
| 28 | + - Visual feedback when copied (checkmark, tooltip, color change) | ||
| 29 | + - Preserve formatting when copying (plain text or markdown) | ||
| 30 | + | ||
| 31 | + ### Code Blocks | ||
| 32 | + - Every code block should have a dedicated copy button | ||
| 33 | + - Button positioned in top-right corner of code block (standard pattern) | ||
| 34 | + - Copy raw code without syntax highlighting markup | ||
| 35 | + - Support for inline code spans (optional - may be too small for button) | ||
| 36 | + - Language label displayed alongside copy button | ||
| 37 | + | ||
| 38 | + ### Multi-line Text Blocks | ||
| 39 | + - Bash command outputs | ||
| 40 | + - File content previews | ||
| 41 | + - Error messages | ||
| 42 | + - Log outputs | ||
| 43 | + - Any multi-line formatted text | ||
| 44 | + | ||
| 45 | + ## User Experience | ||
| 46 | + | ||
| 47 | + ### Visual Design | ||
| 48 | + - **Copy button icon**: clipboard icon or "Copy" text | ||
| 49 | + - **Position**: Top-right corner of widget/block (consistent placement) | ||
| 50 | + - **Visibility**: | ||
| 51 | + - Always visible (simpler) | ||
| 52 | + - OR show on hover (cleaner UI) | ||
| 53 | + - **Feedback states**: | ||
| 54 | + - Default: clipboard icon | ||
| 55 | + - Hover: subtle highlight | ||
| 56 | + - Click: changes to checkmark icon | ||
| 57 | + - After 2s: reverts to clipboard icon | ||
| 58 | + - **Tooltip**: "Copy" (before), "Copied!" (after) | ||
| 59 | + | ||
| 60 | + ### Interaction Flow | ||
| 61 | + ``` | ||
| 62 | + User hovers over code block | ||
| 63 | + → Copy button appears/highlights | ||
| 64 | + → User clicks copy button | ||
| 65 | + → Content copied to clipboard | ||
| 66 | + → Button shows checkmark + "Copied!" tooltip | ||
| 67 | + → After 2 seconds, button reverts to default | ||
| 68 | + ``` | ||
| 69 | + | ||
| 70 | + ## Implementation Considerations | ||
| 71 | + | ||
| 72 | + ### Frontend (React) | ||
| 73 | + - Use Clipboard API (`navigator.clipboard.writeText()`) | ||
| 74 | + - Fallback for older browsers (`document.execCommand('copy')`) | ||
| 75 | + - Component: `<CopyButton content={text} />` | ||
| 76 | + - State management for "copied" feedback | ||
| 77 | + - Debounce multiple rapid clicks | ||
| 78 | + | ||
| 79 | + ### Code Block Integration | ||
| 80 | + - Monaco editor code blocks: extract raw text | ||
| 81 | + - Markdown code blocks: access pre-rendered content | ||
| 82 | + - Syntax highlighted blocks: strip HTML/CSS, keep plain text | ||
| 83 | + | ||
| 84 | + ### Content Processing | ||
| 85 | + - **Code blocks**: Copy raw code, preserve indentation | ||
| 86 | + - **Thought widgets**: Copy plain text or markdown? | ||
| 87 | + - **Tool calls**: Copy JSON formatted or pretty-printed? | ||
| 88 | + - **Structured output**: Preserve structure (JSON, YAML, etc.) | ||
| 89 | + - **Tables**: Copy as markdown table or TSV/CSV? | ||
| 90 | + | ||
| 91 | + ### Security & Privacy | ||
| 92 | + - Only copy visible content (no hidden data) | ||
| 93 | + - Sanitize content before copying (no script injection) | ||
| 94 | + - Respect clipboard permissions (prompt if needed) | ||
| 95 | + - No automatic clipboard access (user-initiated only) | ||
| 96 | + | ||
| 97 | + ## Widget Types to Support | ||
| 98 | + | ||
| 99 | + | Widget Type | Copy Format | Priority | | ||
| 100 | + |------------|-------------|----------| | ||
| 101 | + | Code blocks | Raw code | High | | ||
| 102 | + | Thought bubbles | Plain text | High | | ||
| 103 | + | Tool call output | Formatted text | High | | ||
| 104 | + | File previews | Full content | Medium | | ||
| 105 | + | Error messages | Plain text | Medium | | ||
| 106 | + | Bash commands | Command only | Medium | | ||
| 107 | + | Bash output | Output text | Medium | | ||
| 108 | + | Plans | Markdown | Low | | ||
| 109 | + | JSON responses | Formatted JSON | Low | | ||
| 110 | + | Tables | Markdown table | Low | | ||
| 111 | + | ||
| 112 | + ## Example UI Mock | ||
| 113 | + | ||
| 114 | + ``` | ||
| 115 | + ┌─────────────────────────────────────────────┐ | ||
| 116 | + │ Thought: Analyzing the codebase... [📋] │ | ||
| 117 | + │ │ | ||
| 118 | + │ I'll search for the authentication logic... │ | ||
| 119 | + └─────────────────────────────────────────────┘ | ||
| 120 | + | ||
| 121 | + ┌─────────────────────────────────────────────┐ | ||
| 122 | + │ JavaScript [📋] │ | ||
| 123 | + ├─────────────────────────────────────────────┤ | ||
| 124 | + │ function authenticate(user, pass) { │ | ||
| 125 | + │ return bcrypt.compare(pass, user.hash); │ | ||
| 126 | + │ } │ | ||
| 127 | + └─────────────────────────────────────────────┘ | ||
| 128 | + | ||
| 129 | + ┌─────────────────────────────────────────────┐ | ||
| 130 | + │ Bash Output [✓] │ | ||
| 131 | + ├─────────────────────────────────────────────┤ | ||
| 132 | + │ Tests passed: 42 │ | ||
| 133 | + │ Coverage: 87% │ | ||
| 134 | + └─────────────────────────────────────────────┘ | ||
| 135 | + ``` | ||
| 136 | + | ||
| 137 | + ## Accessibility | ||
| 138 | + | ||
| 139 | + - Button has proper ARIA label: `aria-label="Copy code"` | ||
| 140 | + - Keyboard accessible: Tab to button, Enter/Space to activate | ||
| 141 | + - Screen reader announces "Copied to clipboard" | ||
| 142 | + - Focus visible indicator on keyboard navigation | ||
| 143 | + - Sufficient color contrast for button (WCAG AA) | ||
| 144 | + | ||
| 145 | + ## Testing Considerations | ||
| 146 | + | ||
| 147 | + - Test clipboard API support detection | ||
| 148 | + - Test fallback mechanism | ||
| 149 | + - Test with different content types (code, JSON, markdown) | ||
| 150 | + - Test with large content (>1MB) | ||
| 151 | + - Test rapid successive clicks | ||
| 152 | + - Test keyboard navigation | ||
| 153 | + - Cross-browser testing (Chrome, Firefox, Safari, Edge) | ||
| 154 | + - Mobile touch interaction testing | ||
| 155 | + | ||
| 156 | + ## Related Features | ||
| 157 | + | ||
| 158 | + - Could add "Copy all" button for entire conversation | ||
| 159 | + - Export conversation as markdown with all code blocks | ||
| 160 | + - Share snippet functionality (copy + generate shareable link) | ||
| 161 | + - Syntax highlighting preservation option | ||
| 162 | + | ||
| 163 | + ## Open Questions | ||
| 164 | + | ||
| 165 | + - Should copy button be always visible or show on hover? | ||
| 166 | + - Should we support "copy as markdown" vs "copy as plain text" toggle? | ||
| 167 | + - Should inline code (`inline`) have copy buttons or only blocks? | ||
| 168 | + - Maximum content size for clipboard operations? | ||
| 169 | + - Should we track copy events for analytics/UX improvements? | ||
| 170 | + - Mobile long-press behavior (native context menu vs custom)? | ||
| 171 | + | ||
| 172 | + ## Similar Implementations | ||
| 173 | + | ||
| 174 | + - GitHub code blocks (hover, top-right corner) | ||
| 175 | + - ChatGPT code blocks (always visible, next to language label) | ||
| 176 | + - VS Code (inline copy button on hover) | ||
| 177 | + - Stack Overflow (copy button on code blocks) | ||
| 178 | + - Claude.ai web interface (copy button on responses) | ||
| 179 | + | ||
| 180 | + ## Priority | ||
| 181 | + | ||
| 182 | + High - Essential usability feature for developer workflows | ||
| 183 | + | ||
| 184 | + ## Related Issues | ||
| 185 | + | ||
| 186 | + - Could integrate with Monaco editor's native copy functionality | ||
| 187 | + - Related to file tree operations (copy file path, copy file content) | ||
modified
ui/src/app.css +62 -0 | @@ -963,3 +963,65 @@ body { | ||
| 963 | 963 | color: var(--accent); |
| 964 | 964 | border-color: var(--accent); |
| 965 | 965 | } |
| 966 | + | |
| 967 | +/* ---- copy button ---- */ | |
| 968 | + | |
| 969 | +.copy-btn { | |
| 970 | + display: inline-flex; | |
| 971 | + align-items: center; | |
| 972 | + justify-content: center; | |
| 973 | + padding: 0.35rem; | |
| 974 | + background: var(--panel); | |
| 975 | + border: 1px solid var(--border); | |
| 976 | + border-radius: 4px; | |
| 977 | + color: var(--muted); | |
| 978 | + cursor: pointer; | |
| 979 | + transition: all 0.15s ease; | |
| 980 | + font-size: 0; | |
| 981 | +} | |
| 982 | + | |
| 983 | +.copy-btn:hover { | |
| 984 | + background: var(--bg); | |
| 985 | + color: var(--text); | |
| 986 | + border-color: var(--accent); | |
| 987 | +} | |
| 988 | + | |
| 989 | +.copy-btn:active { | |
| 990 | + transform: scale(0.95); | |
| 991 | +} | |
| 992 | + | |
| 993 | +.copy-btn-copied { | |
| 994 | + color: #22c55e; | |
| 995 | + border-color: #22c55e; | |
| 996 | +} | |
| 997 | + | |
| 998 | +.copy-btn svg { | |
| 999 | + display: block; | |
| 1000 | +} | |
| 1001 | + | |
| 1002 | +/* Copy button positioning in various contexts */ | |
| 1003 | + | |
| 1004 | +.code-block-wrapper { | |
| 1005 | + position: relative; | |
| 1006 | +} | |
| 1007 | + | |
| 1008 | +.code-block-wrapper .copy-btn { | |
| 1009 | + position: absolute; | |
| 1010 | + top: 0.5rem; | |
| 1011 | + right: 0.5rem; | |
| 1012 | + z-index: 1; | |
| 1013 | +} | |
| 1014 | + | |
| 1015 | +.thought-summary .copy-btn { | |
| 1016 | + float: right; | |
| 1017 | + margin-left: 0.5rem; | |
| 1018 | +} | |
| 1019 | + | |
| 1020 | +.tool-summary .copy-btn { | |
| 1021 | + margin-left: auto; | |
| 1022 | +} | |
| 1023 | + | |
| 1024 | +.message-agent .copy-btn { | |
| 1025 | + float: right; | |
| 1026 | + margin: 0 0 0.5rem 0.5rem; | |
| 1027 | +} | |
| @@ -963,3 +963,65 @@ body { | |||
| 963 | color: var(--accent); | 963 | color: var(--accent); |
| 964 | border-color: var(--accent); | 964 | border-color: var(--accent); |
| 965 | } | 965 | } |
| 966 | + | ||
| 967 | +/* ---- copy button ---- */ | ||
| 968 | + | ||
| 969 | +.copy-btn { | ||
| 970 | + display: inline-flex; | ||
| 971 | + align-items: center; | ||
| 972 | + justify-content: center; | ||
| 973 | + padding: 0.35rem; | ||
| 974 | + background: var(--panel); | ||
| 975 | + border: 1px solid var(--border); | ||
| 976 | + border-radius: 4px; | ||
| 977 | + color: var(--muted); | ||
| 978 | + cursor: pointer; | ||
| 979 | + transition: all 0.15s ease; | ||
| 980 | + font-size: 0; | ||
| 981 | +} | ||
| 982 | + | ||
| 983 | +.copy-btn:hover { | ||
| 984 | + background: var(--bg); | ||
| 985 | + color: var(--text); | ||
| 986 | + border-color: var(--accent); | ||
| 987 | +} | ||
| 988 | + | ||
| 989 | +.copy-btn:active { | ||
| 990 | + transform: scale(0.95); | ||
| 991 | +} | ||
| 992 | + | ||
| 993 | +.copy-btn-copied { | ||
| 994 | + color: #22c55e; | ||
| 995 | + border-color: #22c55e; | ||
| 996 | +} | ||
| 997 | + | ||
| 998 | +.copy-btn svg { | ||
| 999 | + display: block; | ||
| 1000 | +} | ||
| 1001 | + | ||
| 1002 | +/* Copy button positioning in various contexts */ | ||
| 1003 | + | ||
| 1004 | +.code-block-wrapper { | ||
| 1005 | + position: relative; | ||
| 1006 | +} | ||
| 1007 | + | ||
| 1008 | +.code-block-wrapper .copy-btn { | ||
| 1009 | + position: absolute; | ||
| 1010 | + top: 0.5rem; | ||
| 1011 | + right: 0.5rem; | ||
| 1012 | + z-index: 1; | ||
| 1013 | +} | ||
| 1014 | + | ||
| 1015 | +.thought-summary .copy-btn { | ||
| 1016 | + float: right; | ||
| 1017 | + margin-left: 0.5rem; | ||
| 1018 | +} | ||
| 1019 | + | ||
| 1020 | +.tool-summary .copy-btn { | ||
| 1021 | + margin-left: auto; | ||
| 1022 | +} | ||
| 1023 | + | ||
| 1024 | +.message-agent .copy-btn { | ||
| 1025 | + float: right; | ||
| 1026 | + margin: 0 0 0.5rem 0.5rem; | ||
| 1027 | +} | ||
modified
ui/src/components/ChatThread.tsx +10 -2 | @@ -11,6 +11,7 @@ import type { ThreadItem } from "../reducer"; | ||
| 11 | 11 | import { Markdown } from "./Markdown"; |
| 12 | 12 | import { ToolCallCard } from "./ToolCallCard"; |
| 13 | 13 | import { WorkingIndicator } from "./WorkingIndicator"; |
| 14 | +import { CopyButton } from "./CopyButton"; | |
| 14 | 15 | |
| 15 | 16 | interface ChatThreadProps { |
| 16 | 17 | thread: ThreadItem[]; |
| @@ -50,6 +51,7 @@ function ThreadEntry({ item }: { item: ThreadItem }) { | ||
| 50 | 51 | case "agent": |
| 51 | 52 | return ( |
| 52 | 53 | <div className="message message-agent"> |
| 54 | + <CopyButton content={item.text} label="Copy response" /> | |
| 53 | 55 | <Markdown text={item.text} /> |
| 54 | 56 | </div> |
| 55 | 57 | ); |
| @@ -75,7 +77,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) { | ||
| 75 | 77 | if (!closed) { |
| 76 | 78 | return ( |
| 77 | 79 | <div className="thought thought-live"> |
| 78 | - <p className="thought-summary">💭 Thinking…</p> | |
| 80 | + <p className="thought-summary"> | |
| 81 | + 💭 Thinking… | |
| 82 | + <CopyButton content={text} label="Copy thought" /> | |
| 83 | + </p> | |
| 79 | 84 | <div className="thought-body"> |
| 80 | 85 | <Markdown text={text} /> |
| 81 | 86 | </div> |
| @@ -84,7 +89,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) { | ||
| 84 | 89 | } |
| 85 | 90 | return ( |
| 86 | 91 | <details className="thought"> |
| 87 | - <summary className="thought-summary">💭 Thought for a moment</summary> | |
| 92 | + <summary className="thought-summary"> | |
| 93 | + 💭 Thought for a moment | |
| 94 | + <CopyButton content={text} label="Copy thought" /> | |
| 95 | + </summary> | |
| 88 | 96 | <div className="thought-body"> |
| 89 | 97 | <Markdown text={text} /> |
| 90 | 98 | </div> |
| @@ -11,6 +11,7 @@ import type { ThreadItem } from "../reducer"; | |||
| 11 | import { Markdown } from "./Markdown"; | 11 | import { Markdown } from "./Markdown"; |
| 12 | import { ToolCallCard } from "./ToolCallCard"; | 12 | import { ToolCallCard } from "./ToolCallCard"; |
| 13 | import { WorkingIndicator } from "./WorkingIndicator"; | 13 | import { WorkingIndicator } from "./WorkingIndicator"; |
| 14 | +import { CopyButton } from "./CopyButton"; | ||
| 14 | 15 | ||
| 15 | interface ChatThreadProps { | 16 | interface ChatThreadProps { |
| 16 | thread: ThreadItem[]; | 17 | thread: ThreadItem[]; |
| @@ -50,6 +51,7 @@ function ThreadEntry({ item }: { item: ThreadItem }) { | |||
| 50 | case "agent": | 51 | case "agent": |
| 51 | return ( | 52 | return ( |
| 52 | <div className="message message-agent"> | 53 | <div className="message message-agent"> |
| 54 | + <CopyButton content={item.text} label="Copy response" /> | ||
| 53 | <Markdown text={item.text} /> | 55 | <Markdown text={item.text} /> |
| 54 | </div> | 56 | </div> |
| 55 | ); | 57 | ); |
| @@ -75,7 +77,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) { | |||
| 75 | if (!closed) { | 77 | if (!closed) { |
| 76 | return ( | 78 | return ( |
| 77 | <div className="thought thought-live"> | 79 | <div className="thought thought-live"> |
| 78 | - <p className="thought-summary">💭 Thinking…</p> | 80 | + <p className="thought-summary"> |
| 81 | + 💭 Thinking… | ||
| 82 | + <CopyButton content={text} label="Copy thought" /> | ||
| 83 | + </p> | ||
| 79 | <div className="thought-body"> | 84 | <div className="thought-body"> |
| 80 | <Markdown text={text} /> | 85 | <Markdown text={text} /> |
| 81 | </div> | 86 | </div> |
| @@ -84,7 +89,10 @@ function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) { | |||
| 84 | } | 89 | } |
| 85 | return ( | 90 | return ( |
| 86 | <details className="thought"> | 91 | <details className="thought"> |
| 87 | - <summary className="thought-summary">💭 Thought for a moment</summary> | 92 | + <summary className="thought-summary"> |
| 93 | + 💭 Thought for a moment | ||
| 94 | + <CopyButton content={text} label="Copy thought" /> | ||
| 95 | + </summary> | ||
| 88 | <div className="thought-body"> | 96 | <div className="thought-body"> |
| 89 | <Markdown text={text} /> | 97 | <Markdown text={text} /> |
| 90 | </div> | 98 | </div> |
added
ui/src/components/CopyButton.test.tsx +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +import { describe, it, expect, beforeEach, vi } from "vitest"; | |
| 2 | +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; | |
| 3 | +import { CopyButton } from "./CopyButton"; | |
| 4 | + | |
| 5 | +describe("CopyButton", () => { | |
| 6 | + let mockWriteText: ReturnType<typeof vi.fn>; | |
| 7 | + | |
| 8 | + beforeEach(() => { | |
| 9 | + mockWriteText = vi.fn(() => Promise.resolve()); | |
| 10 | + // Mock clipboard API with secure context | |
| 11 | + Object.assign(navigator, { | |
| 12 | + clipboard: { | |
| 13 | + writeText: mockWriteText, | |
| 14 | + }, | |
| 15 | + }); | |
| 16 | + // Mock secure context | |
| 17 | + Object.defineProperty(window, "isSecureContext", { | |
| 18 | + value: true, | |
| 19 | + writable: true, | |
| 20 | + }); | |
| 21 | + // Mock execCommand as fallback | |
| 22 | + document.execCommand = vi.fn(() => true); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("renders with default label", () => { | |
| 26 | + render(<CopyButton content="test content" />); | |
| 27 | + const button = screen.getByRole("button", { name: "Copy" }); | |
| 28 | + expect(button).toBeTruthy(); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("renders with custom label", () => { | |
| 32 | + render(<CopyButton content="test" label="Copy code" />); | |
| 33 | + const button = screen.getByRole("button", { name: "Copy code" }); | |
| 34 | + expect(button).toBeTruthy(); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it("copies content to clipboard on click", async () => { | |
| 38 | + render(<CopyButton content="hello world" />); | |
| 39 | + const button = screen.getByRole("button"); | |
| 40 | + | |
| 41 | + fireEvent.click(button); | |
| 42 | + | |
| 43 | + await waitFor(() => { | |
| 44 | + expect(mockWriteText).toHaveBeenCalledWith("hello world"); | |
| 45 | + }); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("shows copied state after click", async () => { | |
| 49 | + render(<CopyButton content="test" />); | |
| 50 | + const button = screen.getByRole("button"); | |
| 51 | + | |
| 52 | + fireEvent.click(button); | |
| 53 | + | |
| 54 | + // Button should show "Copied!" label after async operation | |
| 55 | + await waitFor(() => { | |
| 56 | + expect(button.getAttribute("aria-label")).toBe("Copied!"); | |
| 57 | + expect(button.classList.contains("copy-btn-copied")).toBe(true); | |
| 58 | + }); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("applies custom className", () => { | |
| 62 | + render(<CopyButton content="test" className="custom-class" />); | |
| 63 | + const button = screen.getByRole("button"); | |
| 64 | + expect(button.classList.contains("custom-class")).toBe(true); | |
| 65 | + }); | |
| 66 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +import { describe, it, expect, beforeEach, vi } from "vitest"; | ||
| 2 | +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; | ||
| 3 | +import { CopyButton } from "./CopyButton"; | ||
| 4 | + | ||
| 5 | +describe("CopyButton", () => { | ||
| 6 | + let mockWriteText: ReturnType<typeof vi.fn>; | ||
| 7 | + | ||
| 8 | + beforeEach(() => { | ||
| 9 | + mockWriteText = vi.fn(() => Promise.resolve()); | ||
| 10 | + // Mock clipboard API with secure context | ||
| 11 | + Object.assign(navigator, { | ||
| 12 | + clipboard: { | ||
| 13 | + writeText: mockWriteText, | ||
| 14 | + }, | ||
| 15 | + }); | ||
| 16 | + // Mock secure context | ||
| 17 | + Object.defineProperty(window, "isSecureContext", { | ||
| 18 | + value: true, | ||
| 19 | + writable: true, | ||
| 20 | + }); | ||
| 21 | + // Mock execCommand as fallback | ||
| 22 | + document.execCommand = vi.fn(() => true); | ||
| 23 | + }); | ||
| 24 | + | ||
| 25 | + it("renders with default label", () => { | ||
| 26 | + render(<CopyButton content="test content" />); | ||
| 27 | + const button = screen.getByRole("button", { name: "Copy" }); | ||
| 28 | + expect(button).toBeTruthy(); | ||
| 29 | + }); | ||
| 30 | + | ||
| 31 | + it("renders with custom label", () => { | ||
| 32 | + render(<CopyButton content="test" label="Copy code" />); | ||
| 33 | + const button = screen.getByRole("button", { name: "Copy code" }); | ||
| 34 | + expect(button).toBeTruthy(); | ||
| 35 | + }); | ||
| 36 | + | ||
| 37 | + it("copies content to clipboard on click", async () => { | ||
| 38 | + render(<CopyButton content="hello world" />); | ||
| 39 | + const button = screen.getByRole("button"); | ||
| 40 | + | ||
| 41 | + fireEvent.click(button); | ||
| 42 | + | ||
| 43 | + await waitFor(() => { | ||
| 44 | + expect(mockWriteText).toHaveBeenCalledWith("hello world"); | ||
| 45 | + }); | ||
| 46 | + }); | ||
| 47 | + | ||
| 48 | + it("shows copied state after click", async () => { | ||
| 49 | + render(<CopyButton content="test" />); | ||
| 50 | + const button = screen.getByRole("button"); | ||
| 51 | + | ||
| 52 | + fireEvent.click(button); | ||
| 53 | + | ||
| 54 | + // Button should show "Copied!" label after async operation | ||
| 55 | + await waitFor(() => { | ||
| 56 | + expect(button.getAttribute("aria-label")).toBe("Copied!"); | ||
| 57 | + expect(button.classList.contains("copy-btn-copied")).toBe(true); | ||
| 58 | + }); | ||
| 59 | + }); | ||
| 60 | + | ||
| 61 | + it("applies custom className", () => { | ||
| 62 | + render(<CopyButton content="test" className="custom-class" />); | ||
| 63 | + const button = screen.getByRole("button"); | ||
| 64 | + expect(button.classList.contains("custom-class")).toBe(true); | ||
| 65 | + }); | ||
| 66 | +}); | ||
added
ui/src/components/CopyButton.tsx +90 -0 | new file mode 100644 | ||
| @@ -0,0 +1,90 @@ | ||
| 1 | +/** | |
| 2 | + * CopyButton provides a reusable copy-to-clipboard button with visual feedback. | |
| 3 | + * Shows a clipboard icon that changes to a checkmark on successful copy. | |
| 4 | + * | |
| 5 | + * @example | |
| 6 | + * <CopyButton content="const x = 42;" label="Copy code" /> | |
| 7 | + */ | |
| 8 | + | |
| 9 | +import { useState } from "react"; | |
| 10 | + | |
| 11 | +interface CopyButtonProps { | |
| 12 | + content: string; | |
| 13 | + label?: string; | |
| 14 | + className?: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +export function CopyButton({ | |
| 18 | + content, | |
| 19 | + label = "Copy", | |
| 20 | + className = "", | |
| 21 | +}: CopyButtonProps) { | |
| 22 | + const [copied, setCopied] = useState(false); | |
| 23 | + | |
| 24 | + const handleCopy = async () => { | |
| 25 | + try { | |
| 26 | + // Use modern Clipboard API with fallback | |
| 27 | + if (navigator.clipboard && window.isSecureContext) { | |
| 28 | + await navigator.clipboard.writeText(content); | |
| 29 | + } else { | |
| 30 | + // Fallback for older browsers or non-HTTPS contexts | |
| 31 | + const textArea = document.createElement("textarea"); | |
| 32 | + textArea.value = content; | |
| 33 | + textArea.style.position = "fixed"; | |
| 34 | + textArea.style.left = "-999999px"; | |
| 35 | + textArea.style.top = "-999999px"; | |
| 36 | + document.body.appendChild(textArea); | |
| 37 | + textArea.focus(); | |
| 38 | + textArea.select(); | |
| 39 | + document.execCommand("copy"); | |
| 40 | + textArea.remove(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + setCopied(true); | |
| 44 | + setTimeout(() => setCopied(false), 2000); | |
| 45 | + } catch (err) { | |
| 46 | + console.error("Failed to copy:", err); | |
| 47 | + } | |
| 48 | + }; | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <button | |
| 52 | + type="button" | |
| 53 | + className={`copy-btn ${copied ? "copy-btn-copied" : ""} ${className}`} | |
| 54 | + onClick={handleCopy} | |
| 55 | + aria-label={copied ? "Copied!" : label} | |
| 56 | + title={copied ? "Copied!" : label} | |
| 57 | + > | |
| 58 | + {copied ? ( | |
| 59 | + <svg | |
| 60 | + width="16" | |
| 61 | + height="16" | |
| 62 | + viewBox="0 0 16 16" | |
| 63 | + fill="none" | |
| 64 | + stroke="currentColor" | |
| 65 | + strokeWidth="2" | |
| 66 | + strokeLinecap="round" | |
| 67 | + strokeLinejoin="round" | |
| 68 | + aria-hidden="true" | |
| 69 | + > | |
| 70 | + <polyline points="4,8 7,11 12,5" /> | |
| 71 | + </svg> | |
| 72 | + ) : ( | |
| 73 | + <svg | |
| 74 | + width="16" | |
| 75 | + height="16" | |
| 76 | + viewBox="0 0 16 16" | |
| 77 | + fill="none" | |
| 78 | + stroke="currentColor" | |
| 79 | + strokeWidth="1.5" | |
| 80 | + strokeLinecap="round" | |
| 81 | + strokeLinejoin="round" | |
| 82 | + aria-hidden="true" | |
| 83 | + > | |
| 84 | + <rect x="5" y="5" width="9" height="9" rx="1" /> | |
| 85 | + <path d="M3 11V3a1 1 0 0 1 1-1h8" /> | |
| 86 | + </svg> | |
| 87 | + )} | |
| 88 | + </button> | |
| 89 | + ); | |
| 90 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,90 @@ | |||
| 1 | +/** | ||
| 2 | + * CopyButton provides a reusable copy-to-clipboard button with visual feedback. | ||
| 3 | + * Shows a clipboard icon that changes to a checkmark on successful copy. | ||
| 4 | + * | ||
| 5 | + * @example | ||
| 6 | + * <CopyButton content="const x = 42;" label="Copy code" /> | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +import { useState } from "react"; | ||
| 10 | + | ||
| 11 | +interface CopyButtonProps { | ||
| 12 | + content: string; | ||
| 13 | + label?: string; | ||
| 14 | + className?: string; | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +export function CopyButton({ | ||
| 18 | + content, | ||
| 19 | + label = "Copy", | ||
| 20 | + className = "", | ||
| 21 | +}: CopyButtonProps) { | ||
| 22 | + const [copied, setCopied] = useState(false); | ||
| 23 | + | ||
| 24 | + const handleCopy = async () => { | ||
| 25 | + try { | ||
| 26 | + // Use modern Clipboard API with fallback | ||
| 27 | + if (navigator.clipboard && window.isSecureContext) { | ||
| 28 | + await navigator.clipboard.writeText(content); | ||
| 29 | + } else { | ||
| 30 | + // Fallback for older browsers or non-HTTPS contexts | ||
| 31 | + const textArea = document.createElement("textarea"); | ||
| 32 | + textArea.value = content; | ||
| 33 | + textArea.style.position = "fixed"; | ||
| 34 | + textArea.style.left = "-999999px"; | ||
| 35 | + textArea.style.top = "-999999px"; | ||
| 36 | + document.body.appendChild(textArea); | ||
| 37 | + textArea.focus(); | ||
| 38 | + textArea.select(); | ||
| 39 | + document.execCommand("copy"); | ||
| 40 | + textArea.remove(); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + setCopied(true); | ||
| 44 | + setTimeout(() => setCopied(false), 2000); | ||
| 45 | + } catch (err) { | ||
| 46 | + console.error("Failed to copy:", err); | ||
| 47 | + } | ||
| 48 | + }; | ||
| 49 | + | ||
| 50 | + return ( | ||
| 51 | + <button | ||
| 52 | + type="button" | ||
| 53 | + className={`copy-btn ${copied ? "copy-btn-copied" : ""} ${className}`} | ||
| 54 | + onClick={handleCopy} | ||
| 55 | + aria-label={copied ? "Copied!" : label} | ||
| 56 | + title={copied ? "Copied!" : label} | ||
| 57 | + > | ||
| 58 | + {copied ? ( | ||
| 59 | + <svg | ||
| 60 | + width="16" | ||
| 61 | + height="16" | ||
| 62 | + viewBox="0 0 16 16" | ||
| 63 | + fill="none" | ||
| 64 | + stroke="currentColor" | ||
| 65 | + strokeWidth="2" | ||
| 66 | + strokeLinecap="round" | ||
| 67 | + strokeLinejoin="round" | ||
| 68 | + aria-hidden="true" | ||
| 69 | + > | ||
| 70 | + <polyline points="4,8 7,11 12,5" /> | ||
| 71 | + </svg> | ||
| 72 | + ) : ( | ||
| 73 | + <svg | ||
| 74 | + width="16" | ||
| 75 | + height="16" | ||
| 76 | + viewBox="0 0 16 16" | ||
| 77 | + fill="none" | ||
| 78 | + stroke="currentColor" | ||
| 79 | + strokeWidth="1.5" | ||
| 80 | + strokeLinecap="round" | ||
| 81 | + strokeLinejoin="round" | ||
| 82 | + aria-hidden="true" | ||
| 83 | + > | ||
| 84 | + <rect x="5" y="5" width="9" height="9" rx="1" /> | ||
| 85 | + <path d="M3 11V3a1 1 0 0 1 1-1h8" /> | ||
| 86 | + </svg> | ||
| 87 | + )} | ||
| 88 | + </button> | ||
| 89 | + ); | ||
| 90 | +} | ||
modified
ui/src/components/Markdown.tsx +24 -1 | @@ -9,11 +9,34 @@ | ||
| 9 | 9 | |
| 10 | 10 | import ReactMarkdown from "react-markdown"; |
| 11 | 11 | import remarkGfm from "remark-gfm"; |
| 12 | +import type { Components } from "react-markdown"; | |
| 13 | +import { CopyButton } from "./CopyButton"; | |
| 12 | 14 | |
| 13 | 15 | export function Markdown({ text }: { text: string }) { |
| 16 | + const components: Components = { | |
| 17 | + pre: ({ children, ...props }) => { | |
| 18 | + // Extract code content from pre > code structure | |
| 19 | + const codeElement = children as React.ReactElement; | |
| 20 | + const codeContent = | |
| 21 | + codeElement?.props?.children && | |
| 22 | + typeof codeElement.props.children === "string" | |
| 23 | + ? codeElement.props.children | |
| 24 | + : ""; | |
| 25 | + | |
| 26 | + return ( | |
| 27 | + <div className="code-block-wrapper"> | |
| 28 | + {codeContent && <CopyButton content={codeContent} label="Copy code" />} | |
| 29 | + <pre {...props}>{children}</pre> | |
| 30 | + </div> | |
| 31 | + ); | |
| 32 | + }, | |
| 33 | + }; | |
| 34 | + | |
| 14 | 35 | return ( |
| 15 | 36 | <div className="markdown"> |
| 16 | - <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> | |
| 37 | + <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> | |
| 38 | + {text} | |
| 39 | + </ReactMarkdown> | |
| 17 | 40 | </div> |
| 18 | 41 | ); |
| 19 | 42 | } |
| @@ -9,11 +9,34 @@ | |||
| 9 | 9 | ||
| 10 | import ReactMarkdown from "react-markdown"; | 10 | import ReactMarkdown from "react-markdown"; |
| 11 | import remarkGfm from "remark-gfm"; | 11 | import remarkGfm from "remark-gfm"; |
| 12 | +import type { Components } from "react-markdown"; | ||
| 13 | +import { CopyButton } from "./CopyButton"; | ||
| 12 | 14 | ||
| 13 | export function Markdown({ text }: { text: string }) { | 15 | export function Markdown({ text }: { text: string }) { |
| 16 | + const components: Components = { | ||
| 17 | + pre: ({ children, ...props }) => { | ||
| 18 | + // Extract code content from pre > code structure | ||
| 19 | + const codeElement = children as React.ReactElement; | ||
| 20 | + const codeContent = | ||
| 21 | + codeElement?.props?.children && | ||
| 22 | + typeof codeElement.props.children === "string" | ||
| 23 | + ? codeElement.props.children | ||
| 24 | + : ""; | ||
| 25 | + | ||
| 26 | + return ( | ||
| 27 | + <div className="code-block-wrapper"> | ||
| 28 | + {codeContent && <CopyButton content={codeContent} label="Copy code" />} | ||
| 29 | + <pre {...props}>{children}</pre> | ||
| 30 | + </div> | ||
| 31 | + ); | ||
| 32 | + }, | ||
| 33 | + }; | ||
| 34 | + | ||
| 14 | return ( | 35 | return ( |
| 15 | <div className="markdown"> | 36 | <div className="markdown"> |
| 16 | - <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> | 37 | + <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> |
| 38 | + {text} | ||
| 39 | + </ReactMarkdown> | ||
| 17 | </div> | 40 | </div> |
| 18 | ); | 41 | ); |
| 19 | } | 42 | } |
modified
ui/src/components/ToolCallCard.tsx +24 -0 | @@ -12,6 +12,7 @@ import type { ToolCall } from "../reducer"; | ||
| 12 | 12 | import { InlineText } from "./InlineText"; |
| 13 | 13 | import type { AcpToolCallContent } from "../protocol"; |
| 14 | 14 | import { Markdown } from "./Markdown"; |
| 15 | +import { CopyButton } from "./CopyButton"; | |
| 15 | 16 | |
| 16 | 17 | const kindIcons: Record<string, string> = { |
| 17 | 18 | read: "📖", |
| @@ -34,6 +35,26 @@ const statusLabels: Record<string, string> = { | ||
| 34 | 35 | }; |
| 35 | 36 | |
| 36 | 37 | export function ToolCallCard({ call }: { call: ToolCall }) { |
| 38 | + // Extract all textual content for copy button | |
| 39 | + const getToolContent = (): string => { | |
| 40 | + return call.contents | |
| 41 | + .map((content) => { | |
| 42 | + if (content.type === "content" && content.content?.text) { | |
| 43 | + return content.content.text; | |
| 44 | + } | |
| 45 | + if (content.type === "diff") { | |
| 46 | + const parts = []; | |
| 47 | + if (content.path) parts.push(`Path: ${content.path}`); | |
| 48 | + if (content.oldText) parts.push(`Old:\n${content.oldText}`); | |
| 49 | + if (content.newText) parts.push(`New:\n${content.newText}`); | |
| 50 | + return parts.join("\n\n"); | |
| 51 | + } | |
| 52 | + return ""; | |
| 53 | + }) | |
| 54 | + .filter(Boolean) | |
| 55 | + .join("\n\n"); | |
| 56 | + }; | |
| 57 | + | |
| 37 | 58 | return ( |
| 38 | 59 | <details className={`tool tool-${call.status}`}> |
| 39 | 60 | <summary className="tool-summary"> |
| @@ -46,6 +67,9 @@ export function ToolCallCard({ call }: { call: ToolCall }) { | ||
| 46 | 67 | <span className={`tool-status tool-status-${call.status}`}> |
| 47 | 68 | {statusLabels[call.status] ?? call.status} |
| 48 | 69 | </span> |
| 70 | + {getToolContent() && ( | |
| 71 | + <CopyButton content={getToolContent()} label="Copy tool output" /> | |
| 72 | + )} | |
| 49 | 73 | </summary> |
| 50 | 74 | <div className="tool-body"> |
| 51 | 75 | {call.locations.length > 0 && ( |
| @@ -12,6 +12,7 @@ import type { ToolCall } from "../reducer"; | |||
| 12 | import { InlineText } from "./InlineText"; | 12 | import { InlineText } from "./InlineText"; |
| 13 | import type { AcpToolCallContent } from "../protocol"; | 13 | import type { AcpToolCallContent } from "../protocol"; |
| 14 | import { Markdown } from "./Markdown"; | 14 | import { Markdown } from "./Markdown"; |
| 15 | +import { CopyButton } from "./CopyButton"; | ||
| 15 | 16 | ||
| 16 | const kindIcons: Record<string, string> = { | 17 | const kindIcons: Record<string, string> = { |
| 17 | read: "📖", | 18 | read: "📖", |
| @@ -34,6 +35,26 @@ const statusLabels: Record<string, string> = { | |||
| 34 | }; | 35 | }; |
| 35 | 36 | ||
| 36 | export function ToolCallCard({ call }: { call: ToolCall }) { | 37 | export function ToolCallCard({ call }: { call: ToolCall }) { |
| 38 | + // Extract all textual content for copy button | ||
| 39 | + const getToolContent = (): string => { | ||
| 40 | + return call.contents | ||
| 41 | + .map((content) => { | ||
| 42 | + if (content.type === "content" && content.content?.text) { | ||
| 43 | + return content.content.text; | ||
| 44 | + } | ||
| 45 | + if (content.type === "diff") { | ||
| 46 | + const parts = []; | ||
| 47 | + if (content.path) parts.push(`Path: ${content.path}`); | ||
| 48 | + if (content.oldText) parts.push(`Old:\n${content.oldText}`); | ||
| 49 | + if (content.newText) parts.push(`New:\n${content.newText}`); | ||
| 50 | + return parts.join("\n\n"); | ||
| 51 | + } | ||
| 52 | + return ""; | ||
| 53 | + }) | ||
| 54 | + .filter(Boolean) | ||
| 55 | + .join("\n\n"); | ||
| 56 | + }; | ||
| 57 | + | ||
| 37 | return ( | 58 | return ( |
| 38 | <details className={`tool tool-${call.status}`}> | 59 | <details className={`tool tool-${call.status}`}> |
| 39 | <summary className="tool-summary"> | 60 | <summary className="tool-summary"> |
| @@ -46,6 +67,9 @@ export function ToolCallCard({ call }: { call: ToolCall }) { | |||
| 46 | <span className={`tool-status tool-status-${call.status}`}> | 67 | <span className={`tool-status tool-status-${call.status}`}> |
| 47 | {statusLabels[call.status] ?? call.status} | 68 | {statusLabels[call.status] ?? call.status} |
| 48 | </span> | 69 | </span> |
| 70 | + {getToolContent() && ( | ||
| 71 | + <CopyButton content={getToolContent()} label="Copy tool output" /> | ||
| 72 | + )} | ||
| 49 | </summary> | 73 | </summary> |
| 50 | <div className="tool-body"> | 74 | <div className="tool-body"> |
| 51 | {call.locations.length > 0 && ( | 75 | {call.locations.length > 0 && ( |