From b28edaba02593d00575419c8b3a32c0d934e8921 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 18 Oct 2025 21:03:10 +0000 Subject: [PATCH 1/8] fix: scroll to edit area when editing long historical messages - Added ref to track edit area element - Added useEffect to scroll into view when entering edit mode - Uses smooth scrolling with center block positioning for better UX Fixes #8724 --- webview-ui/src/components/chat/ChatRow.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index b0b87e7a2ddd..d5baf2a66ac1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -136,6 +136,7 @@ export const ChatRowContent = ({ const [editedContent, setEditedContent] = useState("") const [editMode, setEditMode] = useState(mode || "code") const [editImages, setEditImages] = useState([]) + const editAreaRef = useRef(null) // Handle message events for image selection during edit mode useEffect(() => { @@ -165,6 +166,19 @@ export const ChatRowContent = ({ // No need to notify the backend }, [message.text, message.images, mode]) + // Scroll to edit area when entering edit mode + useEffect(() => { + if (isEditing && editAreaRef.current) { + // Use a small delay to ensure the DOM has updated + setTimeout(() => { + editAreaRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }) + }, 100) + } + }, [isEditing]) + // Handle cancel edit const handleCancelEdit = useCallback(() => { setIsEditing(false) @@ -1110,6 +1124,7 @@ export const ChatRowContent = ({ {t("chat:feedback.youSaid")}
Date: Sat, 18 Oct 2025 21:36:08 +0000 Subject: [PATCH 2/8] fix(chat): ensure edit area scrolls into view and clear timeout to avoid leaks --- webview-ui/src/components/chat/ChatRow.tsx | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index d5baf2a66ac1..10de013ef14b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -168,14 +168,21 @@ export const ChatRowContent = ({ // Scroll to edit area when entering edit mode useEffect(() => { - if (isEditing && editAreaRef.current) { - // Use a small delay to ensure the DOM has updated - setTimeout(() => { - editAreaRef.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - }) - }, 100) + if (!isEditing) return + + let cancelled = false + const timeoutId = window.setTimeout(() => { + if (cancelled) return + editAreaRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }) + }, 100) + + return () => { + cancelled = true + window.clearTimeout(timeoutId) } }, [isEditing]) From 97599786fcd56063ee77ec78b496205a99be61f8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 18 Oct 2025 22:03:31 +0000 Subject: [PATCH 3/8] fix(chat): allow editing historical messages while streaming and prevent auto-jump to bottom; ensure edit area scrolls into view --- webview-ui/src/components/chat/ChatRow.tsx | 8 +++++--- webview-ui/src/components/chat/ChatView.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 10de013ef14b..112a2be809d1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1164,9 +1164,11 @@ export const ChatRowContent = ({ className="flex-grow px-2 py-1 wrap-anywhere rounded-lg transition-colors" onClick={(e) => { 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")}> @@ -1174,7 +1176,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..a617e7ba20ea 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1384,7 +1384,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 +1394,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { From a7f2c0aff51c08ac9a0f0e0807e11d14d9844abc Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 18 Oct 2025 22:31:46 +0000 Subject: [PATCH 4/8] fix(chat): focus edit textarea before scrolling to ensure edit mode input mounts and scrolls into view --- webview-ui/src/components/chat/ChatRow.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 112a2be809d1..2a58ec2c81ac 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -137,6 +137,7 @@ export const ChatRowContent = ({ 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(() => { @@ -166,14 +167,25 @@ export const ChatRowContent = ({ // No need to notify the backend }, [message.text, message.images, mode]) - // Scroll to edit area when entering edit mode + // Scroll and focus edit textarea when entering edit mode useEffect(() => { if (!isEditing) return let cancelled = false const timeoutId = window.setTimeout(() => { if (cancelled) return - editAreaRef.current?.scrollIntoView({ + const targetEl = editTextAreaRef.current ?? editAreaRef.current + + // Focus first to ensure caret is visible; preventScroll avoids double-jump + if (editTextAreaRef.current) { + try { + ;(editTextAreaRef.current as any).focus({ preventScroll: true }) + } catch { + editTextAreaRef.current.focus() + } + } + + targetEl?.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest", @@ -1141,6 +1153,7 @@ export const ChatRowContent = ({ {isEditing ? (
Date: Sat, 18 Oct 2025 22:51:21 +0000 Subject: [PATCH 5/8] fix(webview-ui): ensure textarea focus then frame-loop scrollIntoView; fix lint by using const timeoutId --- webview-ui/src/components/chat/ChatRow.tsx | 78 +++++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 2a58ec2c81ac..c442205708f4 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -159,6 +159,17 @@ export const ChatRowContent = ({ // Handle edit button click const handleEditClick = useCallback(() => { + // Pre-scroll the bubble container into view so the textarea can mount fully + try { + editAreaRef.current?.scrollIntoView({ + behavior: "auto", + block: "nearest", + inline: "nearest", + }) + } catch { + // no-op + } + setIsEditing(true) setEditedContent(message.text || "") setEditImages(message.images || []) @@ -167,16 +178,33 @@ export const ChatRowContent = ({ // No need to notify the backend }, [message.text, message.images, mode]) - // Scroll and focus edit textarea when entering edit mode + // Ensure the edit textarea is focused and scrolled into view when entering edit mode. + // Uses a short delay and a few animation frames to allow virtualization reflow before scrolling. useEffect(() => { if (!isEditing) return let cancelled = false - const timeoutId = window.setTimeout(() => { - if (cancelled) return - const targetEl = editTextAreaRef.current ?? editAreaRef.current + let rafId = 0 - // Focus first to ensure caret is visible; preventScroll avoids double-jump + const getScrollContainer = (el: HTMLElement | null): HTMLElement | null => { + if (!el) return null + // ChatView sets the Virtuoso scroller with class "scrollable" + const scroller = el.closest(".scrollable") as HTMLElement | null + return scroller + } + + 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 focusTextarea = () => { if (editTextAreaRef.current) { try { ;(editTextAreaRef.current as any).focus({ preventScroll: true }) @@ -184,17 +212,45 @@ export const ChatRowContent = ({ editTextAreaRef.current.focus() } } + } - targetEl?.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "nearest", - }) - }, 100) + let attempts = 0 + const maxAttempts = 10 + + const step = () => { + if (cancelled) return + attempts += 1 + + const targetEl = (editTextAreaRef.current as HTMLTextAreaElement | null) ?? editAreaRef.current + + // Focus first so caret is visible; preventScroll avoids double-jump + focusTextarea() + + if (targetEl && !isFullyVisible(targetEl)) { + targetEl.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }) + } + + // 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 + const timeoutId = window.setTimeout(() => { + if (!cancelled) { + step() + } + }, 50) return () => { cancelled = true window.clearTimeout(timeoutId) + if (rafId) cancelAnimationFrame(rafId) } }, [isEditing]) From 03ac80fb172843aa4a5f882c12241e7659fbd278 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 18 Oct 2025 23:07:20 +0000 Subject: [PATCH 6/8] fix(webview-ui): preserve edit mode across virtualization; focus textarea before scroll; lift editing state into ChatView and satisfy hook deps --- webview-ui/src/components/chat/ChatRow.tsx | 22 ++++++++++++++------- webview-ui/src/components/chat/ChatView.tsx | 5 +++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index c442205708f4..6a88584e3487 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,12 +131,15 @@ 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([]) @@ -170,13 +177,13 @@ export const ChatRowContent = ({ // no-op } - setIsEditing(true) + 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 the edit textarea is focused and scrolled into view when entering edit mode. // Uses a short delay and a few animation frames to allow virtualization reflow before scrolling. @@ -256,15 +263,14 @@ export const ChatRowContent = ({ // 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", @@ -272,7 +278,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(() => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a617e7ba20ea..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) @@ -1561,6 +1562,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction setEditingTs(null)} /> ) }, @@ -1578,6 +1582,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction Date: Sat, 18 Oct 2025 23:29:41 +0000 Subject: [PATCH 7/8] fix(webview-ui): preserve edit content after virtualization and pre-scroll bubble into view before entering edit mode --- webview-ui/src/components/chat/ChatRow.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 6a88584e3487..84f21f7d7bc1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -185,6 +185,15 @@ export const ChatRowContent = ({ // No need to notify the backend }, [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 a few animation frames to allow virtualization reflow before scrolling. useEffect(() => { From 9059766ac685426ba57c96c3e8fcd59d482e4556 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 18 Oct 2025 23:41:17 +0000 Subject: [PATCH 8/8] fix(webview-ui): robustly center edit area when editing long historical messages after topic switch by recentering within Virtuoso scroller and deferring scroll --- webview-ui/src/components/chat/ChatRow.tsx | 81 +++++++++++++++++----- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 84f21f7d7bc1..334fc669e730 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -166,13 +166,29 @@ export const ChatRowContent = ({ // Handle edit button click const handleEditClick = useCallback(() => { - // Pre-scroll the bubble container into view so the textarea can mount fully + // 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 { - editAreaRef.current?.scrollIntoView({ - behavior: "auto", - block: "nearest", - inline: "nearest", - }) + 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 } @@ -195,7 +211,7 @@ export const ChatRowContent = ({ }, [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 a few animation frames to allow virtualization reflow before scrolling. + // Uses a short delay and repeated frames to allow virtualization reflow before scrolling. useEffect(() => { if (!isEditing) return @@ -205,8 +221,7 @@ export const ChatRowContent = ({ const getScrollContainer = (el: HTMLElement | null): HTMLElement | null => { if (!el) return null // ChatView sets the Virtuoso scroller with class "scrollable" - const scroller = el.closest(".scrollable") as HTMLElement | null - return scroller + return el.closest(".scrollable") as HTMLElement | null } const isFullyVisible = (el: HTMLElement): boolean => { @@ -220,6 +235,22 @@ export const ChatRowContent = ({ 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 { @@ -231,23 +262,35 @@ export const ChatRowContent = ({ } let attempts = 0 - const maxAttempts = 10 + 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 + 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 && !isFullyVisible(targetEl)) { - targetEl.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "nearest", - }) + 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 @@ -256,12 +299,12 @@ export const ChatRowContent = ({ } } - // Defer until after the textarea has mounted + // Defer until after the textarea has mounted and Virtuoso has had a moment to lay out const timeoutId = window.setTimeout(() => { if (!cancelled) { step() } - }, 50) + }, 80) return () => { cancelled = true