Skip to content
Open
173 changes: 163 additions & 10 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
const editAreaRef = useRef<HTMLDivElement>(null)
const editTextAreaRef = useRef<HTMLTextAreaElement>(null)

// Handle message events for image selection during edit mode
useEffect(() => {
Expand All @@ -157,33 +166,173 @@ 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",
value: message.ts,
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(() => {
Expand Down Expand Up @@ -1110,6 +1259,7 @@ export const ChatRowContent = ({
<span style={{ fontWeight: "bold" }}>{t("chat:feedback.youSaid")}</span>
</div>
<div
ref={editAreaRef}
className={cn(
"ml-6 border rounded-sm overflow-hidden whitespace-pre-wrap",
isEditing
Expand All @@ -1119,6 +1269,7 @@ export const ChatRowContent = ({
{isEditing ? (
<div className="flex flex-col gap-2">
<ChatTextArea
ref={editTextAreaRef}
inputValue={editedContent}
setInputValue={setEditedContent}
sendingDisabled={false}
Expand All @@ -1142,17 +1293,19 @@ 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")}>
<Mention text={message.text} withShadow />
</div>
<div className="flex gap-2 pr-1">
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}
style={{ visibility: isStreaming && isLast ? "hidden" : "visible" }}
onClick={(e) => {
e.stopPropagation()
handleEditClick()
Expand Down
11 changes: 9 additions & 2 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const [editingTs, setEditingTs] = useState<number | null>(null)
const prevExpandedRowsRef = useRef<Record<number, boolean>>()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
Expand Down Expand Up @@ -1384,15 +1385,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
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 {
setTimeout(() => scrollToBottomAuto(), 0)
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto],
[scrollToBottomSmooth, scrollToBottomAuto, isAtBottom],
)

useEffect(() => {
Expand Down Expand Up @@ -1559,6 +1562,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
editingTs={editingTs}
onStartEditing={setEditingTs}
onCancelEditing={() => setEditingTs(null)}
/>
)
},
Expand All @@ -1576,6 +1582,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
alwaysAllowUpdateTodoList,
enableButtons,
primaryButtonText,
editingTs,
],
)

Expand Down
Loading