|
1 | 1 | import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' |
2 | | -import { Pencil, RefreshCw, Trash2 } from 'lucide-react' |
| 2 | +import { Maximize2, Minimize2, Pencil, RefreshCw, Trash2 } from 'lucide-react' |
3 | 3 | import ReactMarkdown from 'react-markdown' |
4 | 4 | import { ConversationState, MessageNode as TMessageNode } from '../types' |
5 | 5 | import Composer from './Composer' |
@@ -29,6 +29,10 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
29 | 29 | const [confirming, setConfirming] = useState(false) |
30 | 30 | const [editModelOpen, setEditModelOpen] = useState(false) |
31 | 31 | const editMenuRef = useRef<HTMLDivElement | null>(null) |
| 32 | + const [userExpanded, setUserExpanded] = useState(false) |
| 33 | + |
| 34 | + const cancelDelete = () => setConfirming(false) |
| 35 | + const confirmDelete = () => { setConfirming(false); onDelete(node.id) } |
32 | 36 |
|
33 | 37 | const [retryMenuOpen, setRetryMenuOpen] = useState(false) |
34 | 38 | const [retryMenuPane, setRetryMenuPane] = useState<'root' | 'models'>('root') |
@@ -57,6 +61,25 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
57 | 61 | document.addEventListener('click', onDocClick) |
58 | 62 | return () => { document.removeEventListener('click', onDocClick) } |
59 | 63 | }, [retryMenuOpen]) |
| 64 | + |
| 65 | + useEffect(() => { |
| 66 | + if (!confirming) return |
| 67 | + const onKeyDown = (e: KeyboardEvent) => { |
| 68 | + if (!confirming) return |
| 69 | + if (e.metaKey || e.ctrlKey || e.altKey) return |
| 70 | + if (e.key === 'Enter') { |
| 71 | + e.preventDefault() |
| 72 | + e.stopPropagation() |
| 73 | + confirmDelete() |
| 74 | + } else if (e.key === 'Backspace' || e.key === 'Delete') { |
| 75 | + e.preventDefault() |
| 76 | + e.stopPropagation() |
| 77 | + cancelDelete() |
| 78 | + } |
| 79 | + } |
| 80 | + window.addEventListener('keydown', onKeyDown, { capture: true }) |
| 81 | + return () => { window.removeEventListener('keydown', onKeyDown, { capture: true }) } |
| 82 | + }, [confirming, node.id]) |
60 | 83 | useLayoutEffect(() => { |
61 | 84 | if (!retryMenuOpen) { |
62 | 85 | retryMenuShiftXRef.current = 0 |
@@ -102,6 +125,23 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
102 | 125 | const roleClass = node.role === 'user' ? 'node-user' : node.role === 'assistant' ? 'node-assistant' : 'node-system' |
103 | 126 | const isActive = state.selectedLeafId === node.id |
104 | 127 | const roleLabel = node.role.charAt(0).toUpperCase() + node.role.slice(1).toLowerCase() |
| 128 | + const isLongUserContent = useMemo(() => { |
| 129 | + if (node.role !== 'user') return false |
| 130 | + if (editing) return false |
| 131 | + if (!node.content) return false |
| 132 | + const content = node.content |
| 133 | + const lines = content.split('\n') |
| 134 | + const lineCount = lines.length |
| 135 | + const longestLine = lines.reduce((m, l) => Math.max(m, l.length), 0) |
| 136 | + return content.length >= 800 || lineCount >= 14 || longestLine >= 240 |
| 137 | + }, [editing, node.content, node.role]) |
| 138 | + useEffect(() => { |
| 139 | + if (!isLongUserContent && userExpanded) setUserExpanded(false) |
| 140 | + }, [isLongUserContent, userExpanded]) |
| 141 | + useEffect(() => { |
| 142 | + if (node.role !== 'user') return |
| 143 | + setUserExpanded(false) |
| 144 | + }, [node.content, node.id, node.role]) |
105 | 145 |
|
106 | 146 | return ( |
107 | 147 | <div> |
@@ -150,7 +190,7 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
150 | 190 | )} |
151 | 191 | </div> |
152 | 192 | ) : ( |
153 | | - <div className="node-content plain">{node.content || <span style={{ color: 'var(--muted-2)' }}>GENERATING...</span>}</div> |
| 193 | + <div className={`node-content plain ${isLongUserContent ? (userExpanded ? 'long expanded' : 'long') : ''}`}>{node.content || <span style={{ color: 'var(--muted-2)' }}>GENERATING...</span>}</div> |
154 | 194 | )} |
155 | 195 | </> |
156 | 196 | )} |
@@ -219,6 +259,16 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
219 | 259 | )} |
220 | 260 | {node.role === 'user' && ( |
221 | 261 | <> |
| 262 | + {isLongUserContent && ( |
| 263 | + <button |
| 264 | + className="icon-button" |
| 265 | + aria-label={userExpanded ? 'Collapse long message' : 'Expand long message'} |
| 266 | + title={userExpanded ? 'Collapse' : 'Expand'} |
| 267 | + onClick={() => setUserExpanded(e => !e)} |
| 268 | + > |
| 269 | + {userExpanded ? <Minimize2 size={16} strokeWidth={2} /> : <Maximize2 size={16} strokeWidth={2} />} |
| 270 | + </button> |
| 271 | + )} |
222 | 272 | <button |
223 | 273 | className="icon-button" |
224 | 274 | aria-label="Edit message" |
@@ -278,14 +328,14 @@ export default function MessageNode({ node, state, onSelect, onRetry, onRetryAll |
278 | 328 | )} |
279 | 329 |
|
280 | 330 | {confirming && ( |
281 | | - <div className="modal-overlay" onClick={() => setConfirming(false)}> |
| 331 | + <div className="modal-overlay" onClick={cancelDelete}> |
282 | 332 | <div className="modal" onClick={e => e.stopPropagation()}> |
283 | 333 | <div className="modal-body"> |
284 | 334 | Deleting a message deletes EVERY child messages in EVERY branch. Confirmation to delete? |
285 | 335 | </div> |
286 | 336 | <div className="modal-actions"> |
287 | | - <button className="button pale" onClick={() => setConfirming(false)}>No</button> |
288 | | - <button className="button danger" onClick={() => { setConfirming(false); onDelete(node.id) }}>Yes</button> |
| 337 | + <button className="button pale" onClick={cancelDelete}>No</button> |
| 338 | + <button className="button danger" onClick={confirmDelete}>Yes</button> |
289 | 339 | </div> |
290 | 340 | </div> |
291 | 341 | </div> |
|
0 commit comments