diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index b0b87e7a2ddd..334fc669e730 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -75,6 +75,10 @@ interface ChatRowProps { onFollowUpUnmount?: () => void isFollowUpAnswered?: boolean editable?: boolean + // External edit controls to avoid losing state during virtualization re-mounts + editingTs?: number | null + onStartEditing?: (ts: number) => void + onCancelEditing?: () => void } // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -127,15 +131,20 @@ export const ChatRowContent = ({ onBatchFileResponse, isFollowUpAnswered, editable, + editingTs, + onStartEditing, + onCancelEditing, }: ChatRowContentProps) => { const { t } = useTranslation() const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) - const [isEditing, setIsEditing] = useState(false) + const isEditing = (editingTs ?? null) === message.ts const [editedContent, setEditedContent] = useState("") const [editMode, setEditMode] = useState(mode || "code") const [editImages, setEditImages] = useState([]) + const editAreaRef = useRef(null) + const editTextAreaRef = useRef(null) // Handle message events for image selection during edit mode useEffect(() => { @@ -157,25 +166,163 @@ export const ChatRowContent = ({ // Handle edit button click const handleEditClick = useCallback(() => { - setIsEditing(true) + // Pre-scroll the bubble container into view so the textarea can mount fully. + // Use center to give Virtuoso more room to render around the target. + try { + const el = editAreaRef.current + if (el) { + el.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "nearest", + }) + // Safety re-center on the next frame in case virtualization/layout shifts after state updates. + requestAnimationFrame(() => { + try { + el.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "nearest", + }) + } catch { + // no-op + } + }) + } + } catch { + // no-op + } + + onStartEditing?.(message.ts) setEditedContent(message.text || "") setEditImages(message.images || []) setEditMode(mode || "code") // Edit mode is now handled entirely in the frontend // No need to notify the backend - }, [message.text, message.images, mode]) + }, [message.ts, message.text, message.images, mode, onStartEditing]) + + // Ensure edit fields are initialized when entering edit mode (including after virtualization re-mounts) + useEffect(() => { + if (!isEditing) return + // Only initialize if user hasn't typed yet / images not selected + setEditedContent((prev) => (prev !== "" ? prev : message.text || "")) + setEditImages((prev) => (prev.length > 0 ? prev : message.images || [])) + setEditMode((prev) => prev || mode || "code") + }, [isEditing, message.text, message.images, mode]) + + // Ensure the edit textarea is focused and scrolled into view when entering edit mode. + // Uses a short delay and repeated frames to allow virtualization reflow before scrolling. + useEffect(() => { + if (!isEditing) return + + let cancelled = false + let rafId = 0 + + const getScrollContainer = (el: HTMLElement | null): HTMLElement | null => { + if (!el) return null + // ChatView sets the Virtuoso scroller with class "scrollable" + return el.closest(".scrollable") as HTMLElement | null + } + + const isFullyVisible = (el: HTMLElement): boolean => { + const scroller = getScrollContainer(el) + const rect = el.getBoundingClientRect() + const containerRect = scroller + ? scroller.getBoundingClientRect() + : ({ top: 0, bottom: window.innerHeight } as DOMRect | any) + const topVisible = rect.top >= containerRect.top + 8 + const bottomVisible = rect.bottom <= containerRect.bottom - 8 + return topVisible && bottomVisible + } + + const centerInScroller = (el: HTMLElement) => { + const scroller = getScrollContainer(el) + if (!scroller) { + // Fallback to element's own scroll logic + el.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" }) + return + } + const rect = el.getBoundingClientRect() + const containerRect = scroller.getBoundingClientRect() + const elCenter = rect.top + rect.height / 2 + const containerCenter = containerRect.top + containerRect.height / 2 + const delta = elCenter - containerCenter + // Adjust scrollTop by the delta between element center and container center + scroller.scrollTop += delta + } + + const focusTextarea = () => { + if (editTextAreaRef.current) { + try { + ;(editTextAreaRef.current as any).focus({ preventScroll: true }) + } catch { + editTextAreaRef.current.focus() + } + } + } + + let attempts = 0 + const maxAttempts = 20 // a bit more robust across topic switches + + const step = () => { + if (cancelled) return + attempts += 1 + + const targetEl = + (editTextAreaRef.current as HTMLTextAreaElement | null) ?? (editAreaRef.current as HTMLElement | null) + + // Focus first so caret is visible; preventScroll avoids double-jump + focusTextarea() + + if (targetEl) { + if (!isFullyVisible(targetEl)) { + // Prefer centering within the Virtuoso scroller; fallback to native scrollIntoView + try { + centerInScroller(targetEl) + } catch { + try { + targetEl.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "nearest", + }) + } catch { + // no-op + } + } + } + } + + // Continue for a few frames to account for virtualization/layout reflows + if (!cancelled && attempts < maxAttempts) { + rafId = requestAnimationFrame(step) + } + } + + // Defer until after the textarea has mounted and Virtuoso has had a moment to lay out + const timeoutId = window.setTimeout(() => { + if (!cancelled) { + step() + } + }, 80) + + return () => { + cancelled = true + window.clearTimeout(timeoutId) + if (rafId) cancelAnimationFrame(rafId) + } + }, [isEditing]) // Handle cancel edit const handleCancelEdit = useCallback(() => { - setIsEditing(false) + onCancelEditing?.() setEditedContent(message.text || "") setEditImages(message.images || []) setEditMode(mode || "code") - }, [message.text, message.images, mode]) + }, [message.text, message.images, mode, onCancelEditing]) // Handle save edit const handleSaveEdit = useCallback(() => { - setIsEditing(false) // Send edited message to backend vscode.postMessage({ type: "submitEditedMessage", @@ -183,7 +330,9 @@ export const ChatRowContent = ({ editedMessageContent: editedContent, images: editImages, }) - }, [message.ts, editedContent, editImages]) + // Exit edit mode + onCancelEditing?.() + }, [message.ts, editedContent, editImages, onCancelEditing]) // Handle image selection for editing const handleSelectImages = useCallback(() => { @@ -1110,6 +1259,7 @@ export const ChatRowContent = ({ {t("chat:feedback.youSaid")}
{ e.stopPropagation() - if (!isStreaming) { - handleEditClick() + // Allow editing historical messages even while streaming; only block when this is the last message and streaming + if (isStreaming && isLast) { + return } + handleEditClick() }} title={t("chat:queuedMessages.clickToEdit")}> @@ -1152,7 +1305,7 @@ export const ChatRowContent = ({
{ e.stopPropagation() handleEditClick() diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d358c68f1cff..a8c4750d3a74 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -185,6 +185,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [expandedRows, setExpandedRows] = useState>({}) + const [editingTs, setEditingTs] = useState(null) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) const disableAutoScrollRef = useRef(false) @@ -1384,7 +1385,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (!disableAutoScrollRef.current) { + // Only auto-scroll when user is already at the bottom. + // This prevents editing a historical message from forcing the list to jump to the bottom. + if (!disableAutoScrollRef.current && isAtBottom) { if (isTaller) { scrollToBottomSmooth() } else { @@ -1392,7 +1395,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1559,6 +1562,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction setEditingTs(null)} /> ) }, @@ -1576,6 +1582,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction