fix(desktop): restore chat selection references (#351)

Chat selection references could miss drag gestures that ended outside the
message bubble after the Electron migration. The chat transcript now tracks
selection gestures at the document pointer-up boundary and repositions the
shared selection action near the selected text with above-then-right placement.

Constraint: Electron/WebView selection ranges are not always stable during the message-local mouseup event
Rejected: Add a new selection popover system | the existing shared selection hook already covers chat and workspace dismissal behavior
Confidence: high
Scope-risk: narrow
Directive: Keep chat and workspace selection popovers on the shared positioning/dismissal hook unless their behavior intentionally diverges
Tested: bun run test -- src/components/chat/MessageList.test.tsx -t "selected-message action"
Tested: bun run test -- src/components/chat/MessageList.test.tsx
Tested: bun run check:desktop
Tested: in-app browser smoke at http://127.0.0.1:5181/ with zero console errors
Not-tested: Packaged Electron manual drag selection smoke
Related: #351
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 09:47:57 +08:00
parent 959e177cdf
commit 358b2c50be
3 changed files with 245 additions and 31 deletions

View File

@ -57,7 +57,20 @@ function findTextNodeContaining(container: Element, text: string) {
throw new Error(`Unable to find text node containing ${text}`)
}
async function selectMessageText(element: Element, text: string) {
async function waitForSelectionMenuUpdate() {
await act(async () => {
await Promise.resolve()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
}
function prepareMessageTextSelection(
element: Element,
text: string,
rect: Partial<DOMRect> = {},
) {
const textNode = findTextNodeContaining(element, text)
const startOffset = textNode.textContent?.indexOf(text) ?? -1
const range = document.createRange()
@ -65,14 +78,14 @@ async function selectMessageText(element: Element, text: string) {
range.setEnd(textNode, startOffset + text.length)
Object.assign(range, {
getBoundingClientRect: () => ({
left: 160,
top: 80,
right: 280,
bottom: 98,
width: 120,
height: 18,
x: 160,
y: 80,
left: rect.left ?? 160,
top: rect.top ?? 80,
right: rect.right ?? 280,
bottom: rect.bottom ?? 98,
width: rect.width ?? 120,
height: rect.height ?? 18,
x: rect.x ?? rect.left ?? 160,
y: rect.y ?? rect.top ?? 80,
toJSON: () => ({}),
}),
})
@ -95,10 +108,21 @@ async function selectMessageText(element: Element, text: string) {
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(range)
return selectableRoot ?? element
}
async function selectMessageText(
element: Element,
text: string,
rect: Partial<DOMRect> = {},
) {
prepareMessageTextSelection(element, text, rect)
await act(async () => {
fireEvent.mouseUp(element, { clientX: 260, clientY: 104 })
await Promise.resolve()
})
await waitForSelectionMenuUpdate()
}
describe('MessageList nested tool calls', () => {
@ -1654,6 +1678,85 @@ describe('MessageList nested tool calls', () => {
expect(window.getSelection()?.toString()).toBe('')
})
it('shows the selected-message action when text selection ends outside the message', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'assistant-1',
type: 'assistant_text',
content: 'Drag selection gestures can finish outside the message bubble.',
timestamp: 1,
}],
}),
},
})
render(<MessageList />)
const assistantText = screen.getByText(/Drag selection gestures/)
prepareMessageTextSelection(assistantText, 'selection gestures')
await act(async () => {
fireEvent.pointerDown(assistantText, {
button: 0,
clientX: 172,
clientY: 88,
pointerId: 1,
pointerType: 'mouse',
})
fireEvent.pointerMove(document.body, {
clientX: 640,
clientY: 120,
pointerId: 1,
pointerType: 'mouse',
})
fireEvent.pointerUp(document.body, {
clientX: 640,
clientY: 120,
pointerId: 1,
pointerType: 'mouse',
})
await Promise.resolve()
})
await waitForSelectionMenuUpdate()
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
})
it('places the selected-message action to the right when there is no room above', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{
id: 'assistant-1',
type: 'assistant_text',
content: 'Top edge selections need a nearby right-side action.',
timestamp: 1,
}],
}),
},
})
render(<MessageList />)
const assistantText = screen.getByText(/Top edge selections/)
await selectMessageText(assistantText, 'right-side action', {
left: 160,
top: 18,
right: 280,
bottom: 36,
width: 120,
height: 18,
x: 160,
y: 18,
})
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
expect(floatingAddButton.style.left).toBe('290px')
expect(floatingAddButton.style.top).toBe('12px')
})
it('adds selected assistant reply text to the composer context', async () => {
useChatStore.setState({
sessions: {

View File

@ -75,6 +75,11 @@ type ChatSelectionState = {
y: number
}
type SelectionPointer = {
clientX: number
clientY: number
}
const CHAT_SELECTION_MENU_OFFSET = 10
const CHAT_SELECTION_MENU_WIDTH = 158
const CHAT_SELECTION_MENU_HEIGHT = 44
@ -95,7 +100,7 @@ function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { cl
function getChatSelectionFromContainer(
root: HTMLElement | null,
pointer: { clientX: number; clientY: number },
pointer: SelectionPointer,
): ChatSelectionState | null {
if (!root) return null
const selection = window.getSelection()
@ -117,6 +122,13 @@ function getChatSelectionFromContainer(
}
}
function getSelectionPointer(event: SelectionPointer): SelectionPointer {
return {
clientX: event.clientX,
clientY: event.clientY,
}
}
function ChatSelectionMenu({
selection,
onAdd,
@ -388,6 +400,8 @@ function SelectableChatMessage({
}) {
const rootRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const selectionStartedInsideRef = useRef(false)
const selectionUpdateFrameRef = useRef<number | null>(null)
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
const t = useTranslation()
@ -397,12 +411,51 @@ function SelectableChatMessage({
useEffect(() => {
setSelectionMenu(null)
selectionStartedInsideRef.current = false
}, [content, messageId])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
const queueSelectionMenuUpdate = useCallback((pointer: SelectionPointer) => {
if (selectionUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
}
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
selectionUpdateFrameRef.current = null
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, pointer))
})
}, [])
useEffect(() => {
return () => {
if (selectionUpdateFrameRef.current !== null) {
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
}
}
}, [])
useEffect(() => {
const handlePointerUp = (event: PointerEvent) => {
if (!selectionStartedInsideRef.current) return
selectionStartedInsideRef.current = false
queueSelectionMenuUpdate(getSelectionPointer(event))
}
const handlePointerCancel = () => {
selectionStartedInsideRef.current = false
}
document.addEventListener('pointerup', handlePointerUp, true)
document.addEventListener('pointercancel', handlePointerCancel, true)
return () => {
document.removeEventListener('pointerup', handlePointerUp, true)
document.removeEventListener('pointercancel', handlePointerCancel, true)
}
}, [queueSelectionMenuUpdate])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
@ -426,8 +479,13 @@ function SelectableChatMessage({
return (
<div
ref={rootRef}
onPointerDown={(event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
selectionStartedInsideRef.current = true
}}
onMouseUp={(event) => {
setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event))
selectionStartedInsideRef.current = false
queueSelectionMenuUpdate(getSelectionPointer(event))
}}
onKeyDown={(event) => {
if (event.key === 'Escape') setSelectionMenu(null)

View File

@ -6,10 +6,48 @@ type ElementRef = {
const VIEWPORT_MARGIN = 12
type SelectionRect = {
left: number
top: number
right: number
bottom: number
width: number
height: number
}
function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function isUsableRect(rect: SelectionRect | DOMRect) {
return rect.width > 0 || rect.height > 0
}
function getRangeSelectionRect(range: Range): SelectionRect | null {
const boundingRect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
if (boundingRect && isUsableRect(boundingRect)) return boundingRect
const clientRects = typeof range.getClientRects === 'function'
? Array.from(range.getClientRects()).filter(isUsableRect)
: []
if (clientRects.length === 0) return null
const left = Math.min(...clientRects.map((rect) => rect.left))
const top = Math.min(...clientRects.map((rect) => rect.top))
const right = Math.max(...clientRects.map((rect) => rect.right))
const bottom = Math.max(...clientRects.map((rect) => rect.bottom))
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
}
}
export function clearWindowSelection() {
window.getSelection()?.removeAllRanges()
}
@ -29,39 +67,54 @@ export function getSelectionPopoverPosition(
fallbackPointer?: { clientX: number; clientY: number }
},
) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
const rect = getRangeSelectionRect(range)
const rootRect = root.getBoundingClientRect()
const hasUsableRangeRect = Boolean(rect && (rect.width > 0 || rect.height > 0))
const pointerInsideRoot = fallbackPointer
&& fallbackPointer.clientX >= rootRect.left
&& fallbackPointer.clientX <= rootRect.right
&& fallbackPointer.clientY >= rootRect.top
&& fallbackPointer.clientY <= rootRect.bottom
const fallbackLeft = pointerInsideRoot ? fallbackPointer.clientX - menuWidth / 2 : rootRect.left + 24
const fallbackTop = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
const selectionLeft = hasUsableRangeRect ? rect!.left : fallbackLeft
const selectionRight = hasUsableRangeRect ? rect!.right : selectionLeft + menuWidth
const selectionTop = hasUsableRangeRect ? rect!.top : fallbackTop
const selectionBottom = hasUsableRangeRect ? rect!.bottom : selectionTop + menuHeight
const fallbackX = pointerInsideRoot ? fallbackPointer.clientX : rootRect.left + 24
const fallbackY = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
const selectionRect = rect ?? {
left: fallbackX,
top: fallbackY,
right: fallbackX,
bottom: fallbackY,
width: 0,
height: 0,
}
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN
const minX = VIEWPORT_MARGIN
const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN)
const minY = VIEWPORT_MARGIN
const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN)
const aboveY = selectionTop - menuHeight - offset
const belowY = selectionBottom + offset
const y = aboveY >= VIEWPORT_MARGIN || belowY + menuHeight > viewportHeight - VIEWPORT_MARGIN
? aboveY
: belowY
const centerX = selectionLeft + (selectionRight - selectionLeft) / 2
return {
x: clampValue(centerX - menuWidth / 2, minX, maxX),
y: clampValue(y, minY, maxY),
const clampPosition = (position: { x: number; y: number }) => ({
x: clampValue(position.x, minX, maxX),
y: clampValue(position.y, minY, maxY),
})
const centerX = selectionRect.left + selectionRect.width / 2
const centerY = selectionRect.top + selectionRect.height / 2
const above = {
x: centerX - menuWidth / 2,
y: selectionRect.top - menuHeight - offset,
}
if (above.y >= VIEWPORT_MARGIN) return clampPosition(above)
const right = {
x: selectionRect.right + offset,
y: centerY - menuHeight / 2,
}
if (right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right)
const below = {
x: centerX - menuWidth / 2,
y: selectionRect.bottom + offset,
}
if (below.y + menuHeight <= viewportHeight - VIEWPORT_MARGIN) return clampPosition(below)
return clampPosition(above)
}
export function useSelectionPopoverDismiss({