From f588cdebbbe50b3f3589d101b0f913a4b93c0ed9 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 20 May 2026 22:11:28 +0000 Subject: [PATCH] fix(react-ui/chat): stop wiping selection on every /api/operations poll (#9904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Signed-off-by: Ettore Di Giacinto Assisted-by: claude-opus-4-7-1m --- .../e2e/chat-polling-selection.spec.js | 143 ++++++++++++++++++ .../http/react-ui/public/locales/de/chat.json | 3 +- .../http/react-ui/public/locales/en/chat.json | 3 +- .../http/react-ui/public/locales/es/chat.json | 3 +- .../http/react-ui/public/locales/it/chat.json | 3 +- .../react-ui/public/locales/zh-CN/chat.json | 3 +- .../react-ui/src/components/CanvasPanel.jsx | 11 +- core/http/react-ui/src/hooks/useOperations.js | 28 +++- core/http/react-ui/src/pages/AgentChat.jsx | 11 +- core/http/react-ui/src/pages/Chat.jsx | 11 +- core/http/react-ui/src/utils/clipboard.js | 81 ++++++++++ 11 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 core/http/react-ui/e2e/chat-polling-selection.spec.js create mode 100644 core/http/react-ui/src/utils/clipboard.js diff --git a/core/http/react-ui/e2e/chat-polling-selection.spec.js b/core/http/react-ui/e2e/chat-polling-selection.spec.js new file mode 100644 index 000000000000..97c81e753377 --- /dev/null +++ b/core/http/react-ui/e2e/chat-polling-selection.spec.js @@ -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') + }) +}) diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 7304f8e98810..8b370d1da196 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -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", diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index a4dd502f6dcd..64f6e0650a8e 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -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", diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index 0a57b335c775..4df79286a56b 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -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", diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 6ee046a32136..edb0397d37d4 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -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", diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 509bd0f49960..c810fc96cea7 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -97,7 +97,8 @@ }, "toasts": { "selectModel": "请选择一个模型", - "copied": "已复制到剪贴板" + "copied": "已复制到剪贴板", + "copyFailed": "无法复制到剪贴板" }, "menu": { "trigger": "聊天", diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx index 14b24700b7d9..9d57cff06f53 100644 --- a/core/http/react-ui/src/components/CanvasPanel.jsx +++ b/core/http/react-ui/src/components/CanvasPanel.jsx @@ -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 = () => { diff --git a/core/http/react-ui/src/hooks/useOperations.js b/core/http/react-ui/src/hooks/useOperations.js index d115f458104c..56c2ad84663c 100644 --- a/core/http/react-ui/src/hooks/useOperations.js +++ b/core/http/react-ui/src/hooks/useOperations.js @@ -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]) diff --git a/core/http/react-ui/src/pages/AgentChat.jsx b/core/http/react-ui/src/pages/AgentChat.jsx index 4907c33fd2f3..eb19e1e6a4fb 100644 --- a/core/http/react-ui/src/pages/AgentChat.jsx +++ b/core/http/react-ui/src/pages/AgentChat.jsx @@ -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) => { diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 64141d172398..a638aa3a8a1e 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -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() diff --git a/core/http/react-ui/src/utils/clipboard.js b/core/http/react-ui/src/utils/clipboard.js new file mode 100644 index 000000000000..21c2fe3623c5 --- /dev/null +++ b/core/http/react-ui/src/utils/clipboard.js @@ -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 + } +}