fix(react-ui/chat): stop wiping selection on every /api/operations poll (#9904) (#9917)

useOperations() was calling setOperations() with a fresh array on every
1s poll, even when the payload was identical. In React 19 the DOM diff
no longer short-circuits dangerouslySetInnerHTML on equal __html, so the
forced Chat re-render re-assigned innerHTML on every assistant message
once per second — wiping any text the user had selected.

Skip the state update when the serialised operations payload is
unchanged, and switch loading/error to functional setters so they also
short-circuit at the source.

Also fixes the chat copy button on plain HTTP: navigator.clipboard is
undefined in non-secure contexts (a common LXC+Docker deployment), but
the previous code called it unconditionally and showed a success toast
regardless. Routed Chat, AgentChat and CanvasPanel through a new
copyToClipboard() helper that uses navigator.clipboard when available
and falls back to a hidden-textarea + execCommand('copy') trick that
browsers still honour outside secure contexts. The fallback preserves
the user's existing selection.

Regression coverage in e2e/chat-polling-selection.spec.js: a
MutationObserver counts mutations on the assistant content node across
3s of polling (must be 0); the copy test stubs out navigator.clipboard
and asserts that execCommand('copy') is invoked.


Assisted-by: claude-opus-4-7-1m

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-05-21 12:17:51 +02:00
committed by GitHub
parent 12e056e96d
commit 11d5bd0cc3
11 changed files with 280 additions and 20 deletions

View File

@@ -0,0 +1,143 @@
import { test, expect } from '@playwright/test'
// Regression coverage for issue #9904:
// - /api/operations was polled every 1s and *always* re-rendered the Chat
// page, even when the response was unchanged. The reconciliation would
// collapse any text selection inside an assistant message.
// - The copy button next to each assistant message used navigator.clipboard
// without any fallback, which is undefined when the page is served over
// plain http (non-secure context) from a remote host.
async function setupChatPage(page) {
await page.route('**/api/models/capabilities', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }],
}),
})
})
// Poll-tracking mock: assert the hook is hammering /api/operations every
// ~1s, and always return an empty list so its contents never change.
let operationsHits = 0
await page.route('**/api/operations', (route) => {
operationsHits++
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ operations: [] }),
})
})
await page.route('**/v1/chat/completions', (route) => {
// One short SSE stream so the chat finishes streaming quickly and we
// can interact with a stable assistant message.
const body = [
'data: {"choices":[{"delta":{"content":"Hello world this is a long assistant reply that we can try to select."},"index":0}]}\n\n',
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n',
'data: [DONE]\n\n',
].join('')
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body,
})
})
return { getOperationsHits: () => operationsHits }
}
test.describe('Chat - /api/operations polling (#9904)', () => {
test('text selection inside an assistant message survives polling', async ({ page }) => {
const { getOperationsHits } = await setupChatPage(page)
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hi')
await page.locator('.chat-send-btn').click()
const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first()
await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 })
// Sanity check: the polling we're regressing against is actually firing.
await page.waitForTimeout(2_500)
expect(getOperationsHits()).toBeGreaterThan(1)
// Sanity check that the bug we're guarding against is structurally
// possible: count how many times the assistant content node gets
// *touched* by React (childList / characterData mutations) over a
// 3-second window. Before the fix, every poll re-rendered Chat and
// re-set dangerouslySetInnerHTML, triggering a mutation cascade that
// collapsed the user's text selection. After the fix, polling with
// identical contents must not mutate the DOM at all.
const mutationCount = await assistantContent.evaluate((el) => new Promise((resolve) => {
let count = 0
const obs = new MutationObserver((records) => { count += records.length })
obs.observe(el, { childList: true, subtree: true, characterData: true })
setTimeout(() => { obs.disconnect(); resolve(count) }, 3_000)
}))
expect(mutationCount).toBe(0)
// Same sanity check translated to a user-observable property: a
// programmatically created selection survives the polling window.
await assistantContent.evaluate((el) => {
const range = document.createRange()
range.selectNodeContents(el)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
})
const initialSelection = await page.evaluate(() => window.getSelection().toString())
expect(initialSelection).toContain('Hello world')
await page.waitForTimeout(2_500)
const selectionAfterPolling = await page.evaluate(() => window.getSelection().toString())
expect(selectionAfterPolling).toBe(initialSelection)
})
})
test.describe('Chat - copy button (#9904)', () => {
test('copy button works when navigator.clipboard is unavailable (plain http)', async ({ page }) => {
await setupChatPage(page)
// Simulate a non-secure context: hide navigator.clipboard before any of
// our app code touches it. This mirrors what browsers do over plain
// http from a remote host.
await page.addInitScript(() => {
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
try {
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
} catch { /* some browsers refuse — the secure-context flag is enough */ }
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hi')
await page.locator('.chat-send-btn').click()
const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first()
await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 })
// Spy on document.execCommand so we can confirm the fallback path ran.
await page.evaluate(() => {
window.__execCommandCalls = []
const original = document.execCommand?.bind(document)
document.execCommand = (cmd, ...rest) => {
window.__execCommandCalls.push(cmd)
// execCommand('copy') in a headless browser may return false because
// there is no real clipboard, but the fact that we tried is what we
// care about for this regression.
return original ? original(cmd, ...rest) : false
}
})
await assistantBubble.locator('.chat-message-actions button').first().click()
const execCommandCalls = await page.evaluate(() => window.__execCommandCalls)
expect(execCommandCalls).toContain('copy')
})
})

View File

@@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "Bitte wählen Sie ein Modell",
"copied": "In die Zwischenablage kopiert"
"copied": "In die Zwischenablage kopiert",
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
},
"menu": {
"trigger": "Chats",

View File

@@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "Please select a model",
"copied": "Copied to clipboard"
"copied": "Copied to clipboard",
"copyFailed": "Could not copy to clipboard"
},
"menu": {
"trigger": "Chats",

View File

@@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "Por favor selecciona un modelo",
"copied": "Copiado al portapapeles"
"copied": "Copiado al portapapeles",
"copyFailed": "No se pudo copiar al portapapeles"
},
"menu": {
"trigger": "Chats",

View File

@@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "Seleziona un modello",
"copied": "Copiato negli appunti"
"copied": "Copiato negli appunti",
"copyFailed": "Impossibile copiare negli appunti"
},
"menu": {
"trigger": "Chat",

View File

@@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "请选择一个模型",
"copied": "已复制到剪贴板"
"copied": "已复制到剪贴板",
"copyFailed": "无法复制到剪贴板"
},
"menu": {
"trigger": "聊天",

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { renderMarkdown } from '../utils/markdown'
import { getArtifactIcon } from '../utils/artifacts'
import { safeHref } from '../utils/url'
import { copyToClipboard } from '../utils/clipboard'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js'
@@ -23,11 +24,13 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
}
}, [current, showPreview])
const handleCopy = () => {
const handleCopy = async () => {
const text = current.code || current.url || ''
navigator.clipboard.writeText(text)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
const ok = await copyToClipboard(text)
if (ok) {
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}
const handleDownload = () => {

View File

@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { operationsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
// Serialize ops into a stable comparison key. Each op is a flat map of
// primitives, so JSON.stringify is good enough and stable as long as the
// server emits keys in the same order (Go's map iteration into JSON happens
// to be stable here because we build an explicit map[string]any).
function serializeOps(ops) {
return JSON.stringify(ops)
}
export function useOperations(pollInterval = 1000) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
@@ -11,16 +19,26 @@ export function useOperations(pollInterval = 1000) {
const previousCountRef = useRef(0)
const onAllCompleteRef = useRef(null)
// Track the last payload we wrote into state. Each poll otherwise produces
// a fresh array reference even when nothing changed, and that re-render
// ripples into the Chat page — wiping the user's text selection mid-read
// (#9904).
const lastSerializedRef = useRef('[]')
const fetchOperations = useCallback(async () => {
if (!isAdmin) {
setLoading(false)
setLoading((prev) => (prev ? false : prev))
return
}
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
setOperations(ops)
const serialized = serializeOps(ops)
if (serialized !== lastSerializedRef.current) {
lastSerializedRef.current = serialized
setOperations(ops)
}
// Separate active (non-failed) operations from failed ones
const activeOps = ops.filter(op => !op.error)
@@ -32,11 +50,11 @@ export function useOperations(pollInterval = 1000) {
}
previousCountRef.current = activeOps.length
setError(null)
setError((prev) => (prev === null ? prev : null))
} catch (err) {
setError(err.message)
setError((prev) => (prev === err.message ? prev : err.message))
} finally {
setLoading(false)
setLoading((prev) => (prev ? false : prev))
}
}, [isAdmin])

View File

@@ -9,6 +9,7 @@ import ResourceCards from '../components/ResourceCards'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAgentChat } from '../hooks/useAgentChat'
import { relativeTime } from '../utils/format'
import { copyToClipboard } from '../utils/clipboard'
function getLastMessagePreview(conv) {
if (!conv.messages || conv.messages.length === 0) return ''
@@ -390,9 +391,13 @@ export default function AgentChat() {
}
}
const copyMessage = (content) => {
navigator.clipboard.writeText(content)
addToast('Copied to clipboard', 'success', 2000)
const copyMessage = async (content) => {
const ok = await copyToClipboard(content)
addToast(
ok ? 'Copied to clipboard' : 'Could not copy to clipboard',
ok ? 'success' : 'error',
ok ? 2000 : 3000,
)
}
const senderToRole = (sender) => {

View File

@@ -17,6 +17,7 @@ import ChatsMenu from '../components/ChatsMenu'
import { useAuth } from '../context/AuthContext'
import { useOperations } from '../hooks/useOperations'
import { relativeTime } from '../utils/format'
import { copyToClipboard } from '../utils/clipboard'
function getLastMessagePreview(chat) {
if (!chat.history || chat.history.length === 0) return ''
@@ -798,10 +799,14 @@ export default function Chat() {
}
}
const copyMessage = (content) => {
const copyMessage = async (content) => {
const text = typeof content === 'string' ? content : content?.[0]?.text || ''
navigator.clipboard.writeText(text)
addToast(t('toasts.copied'), 'success', 2000)
const ok = await copyToClipboard(text)
if (ok) {
addToast(t('toasts.copied'), 'success', 2000)
} else {
addToast(t('toasts.copyFailed'), 'error', 3000)
}
}
const contextPercent = getContextUsagePercent()

View File

@@ -0,0 +1,81 @@
// Clipboard helper that works in non-secure contexts.
//
// navigator.clipboard is only defined on https:// origins and on
// http://localhost. When LocalAI is served over plain http from a remote
// host (LXC + Docker is a common deployment), every page that called
// `navigator.clipboard.writeText` silently failed (#9904). This helper
// transparently falls back to a hidden-textarea + execCommand('copy')
// trick that browsers still honour when the page is not a secure context.
//
// Returns true on success, false on failure. Callers should use the return
// value to drive the success/failure toast — the old code always claimed
// success regardless of what actually happened.
export async function copyToClipboard(text) {
if (text == null) return false
const value = typeof text === 'string' ? text : String(text)
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
// Permissions denied, browser refused, etc. — try the fallback.
}
}
return legacyCopy(value)
}
function legacyCopy(value) {
if (typeof document === 'undefined') return false
const ta = document.createElement('textarea')
ta.value = value
// Keep the textarea out of the viewport and out of layout reads. Using
// `position: fixed` + a negative offset avoids scrolling the page when
// we call .select() below.
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.top = '0'
ta.style.left = '-9999px'
ta.style.opacity = '0'
document.body.appendChild(ta)
// Preserve the current selection so triggering execCommand doesn't blow
// away whatever the user had highlighted on the page.
const previousSelection = saveSelection()
let ok = false
try {
ta.select()
ta.setSelectionRange(0, value.length)
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(ta)
restoreSelection(previousSelection)
}
return ok
}
function saveSelection() {
try {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return null
const ranges = []
for (let i = 0; i < sel.rangeCount; i++) ranges.push(sel.getRangeAt(i).cloneRange())
return ranges
} catch {
return null
}
}
function restoreSelection(ranges) {
if (!ranges) return
try {
const sel = window.getSelection()
if (!sel) return
sel.removeAllRanges()
for (const r of ranges) sel.addRange(r)
} catch {
// best-effort
}
}