Skip to content

Commit 11d5bd0

Browse files
localai-botmudler
andauthored
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>
1 parent 12e056e commit 11d5bd0

11 files changed

Lines changed: 280 additions & 20 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
// Regression coverage for issue #9904:
4+
// - /api/operations was polled every 1s and *always* re-rendered the Chat
5+
// page, even when the response was unchanged. The reconciliation would
6+
// collapse any text selection inside an assistant message.
7+
// - The copy button next to each assistant message used navigator.clipboard
8+
// without any fallback, which is undefined when the page is served over
9+
// plain http (non-secure context) from a remote host.
10+
11+
async function setupChatPage(page) {
12+
await page.route('**/api/models/capabilities', (route) => {
13+
route.fulfill({
14+
contentType: 'application/json',
15+
body: JSON.stringify({
16+
data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }],
17+
}),
18+
})
19+
})
20+
21+
// Poll-tracking mock: assert the hook is hammering /api/operations every
22+
// ~1s, and always return an empty list so its contents never change.
23+
let operationsHits = 0
24+
await page.route('**/api/operations', (route) => {
25+
operationsHits++
26+
route.fulfill({
27+
contentType: 'application/json',
28+
body: JSON.stringify({ operations: [] }),
29+
})
30+
})
31+
32+
await page.route('**/v1/chat/completions', (route) => {
33+
// One short SSE stream so the chat finishes streaming quickly and we
34+
// can interact with a stable assistant message.
35+
const body = [
36+
'data: {"choices":[{"delta":{"content":"Hello world this is a long assistant reply that we can try to select."},"index":0}]}\n\n',
37+
'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n',
38+
'data: [DONE]\n\n',
39+
].join('')
40+
route.fulfill({
41+
status: 200,
42+
headers: { 'Content-Type': 'text/event-stream' },
43+
body,
44+
})
45+
})
46+
47+
return { getOperationsHits: () => operationsHits }
48+
}
49+
50+
test.describe('Chat - /api/operations polling (#9904)', () => {
51+
test('text selection inside an assistant message survives polling', async ({ page }) => {
52+
const { getOperationsHits } = await setupChatPage(page)
53+
54+
await page.goto('/app/chat')
55+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
56+
57+
await page.locator('.chat-input').fill('Hi')
58+
await page.locator('.chat-send-btn').click()
59+
60+
const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first()
61+
await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 })
62+
63+
// Sanity check: the polling we're regressing against is actually firing.
64+
await page.waitForTimeout(2_500)
65+
expect(getOperationsHits()).toBeGreaterThan(1)
66+
67+
// Sanity check that the bug we're guarding against is structurally
68+
// possible: count how many times the assistant content node gets
69+
// *touched* by React (childList / characterData mutations) over a
70+
// 3-second window. Before the fix, every poll re-rendered Chat and
71+
// re-set dangerouslySetInnerHTML, triggering a mutation cascade that
72+
// collapsed the user's text selection. After the fix, polling with
73+
// identical contents must not mutate the DOM at all.
74+
const mutationCount = await assistantContent.evaluate((el) => new Promise((resolve) => {
75+
let count = 0
76+
const obs = new MutationObserver((records) => { count += records.length })
77+
obs.observe(el, { childList: true, subtree: true, characterData: true })
78+
setTimeout(() => { obs.disconnect(); resolve(count) }, 3_000)
79+
}))
80+
expect(mutationCount).toBe(0)
81+
82+
// Same sanity check translated to a user-observable property: a
83+
// programmatically created selection survives the polling window.
84+
await assistantContent.evaluate((el) => {
85+
const range = document.createRange()
86+
range.selectNodeContents(el)
87+
const sel = window.getSelection()
88+
sel.removeAllRanges()
89+
sel.addRange(range)
90+
})
91+
92+
const initialSelection = await page.evaluate(() => window.getSelection().toString())
93+
expect(initialSelection).toContain('Hello world')
94+
95+
await page.waitForTimeout(2_500)
96+
97+
const selectionAfterPolling = await page.evaluate(() => window.getSelection().toString())
98+
expect(selectionAfterPolling).toBe(initialSelection)
99+
})
100+
})
101+
102+
test.describe('Chat - copy button (#9904)', () => {
103+
test('copy button works when navigator.clipboard is unavailable (plain http)', async ({ page }) => {
104+
await setupChatPage(page)
105+
106+
// Simulate a non-secure context: hide navigator.clipboard before any of
107+
// our app code touches it. This mirrors what browsers do over plain
108+
// http from a remote host.
109+
await page.addInitScript(() => {
110+
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
111+
try {
112+
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
113+
} catch { /* some browsers refuse — the secure-context flag is enough */ }
114+
})
115+
116+
await page.goto('/app/chat')
117+
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
118+
119+
await page.locator('.chat-input').fill('Hi')
120+
await page.locator('.chat-send-btn').click()
121+
122+
const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first()
123+
await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 })
124+
125+
// Spy on document.execCommand so we can confirm the fallback path ran.
126+
await page.evaluate(() => {
127+
window.__execCommandCalls = []
128+
const original = document.execCommand?.bind(document)
129+
document.execCommand = (cmd, ...rest) => {
130+
window.__execCommandCalls.push(cmd)
131+
// execCommand('copy') in a headless browser may return false because
132+
// there is no real clipboard, but the fact that we tried is what we
133+
// care about for this regression.
134+
return original ? original(cmd, ...rest) : false
135+
}
136+
})
137+
138+
await assistantBubble.locator('.chat-message-actions button').first().click()
139+
140+
const execCommandCalls = await page.evaluate(() => window.__execCommandCalls)
141+
expect(execCommandCalls).toContain('copy')
142+
})
143+
})

core/http/react-ui/public/locales/de/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"toasts": {
9999
"selectModel": "Bitte wählen Sie ein Modell",
100-
"copied": "In die Zwischenablage kopiert"
100+
"copied": "In die Zwischenablage kopiert",
101+
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen"
101102
},
102103
"menu": {
103104
"trigger": "Chats",

core/http/react-ui/public/locales/en/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"toasts": {
9999
"selectModel": "Please select a model",
100-
"copied": "Copied to clipboard"
100+
"copied": "Copied to clipboard",
101+
"copyFailed": "Could not copy to clipboard"
101102
},
102103
"menu": {
103104
"trigger": "Chats",

core/http/react-ui/public/locales/es/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"toasts": {
9999
"selectModel": "Por favor selecciona un modelo",
100-
"copied": "Copiado al portapapeles"
100+
"copied": "Copiado al portapapeles",
101+
"copyFailed": "No se pudo copiar al portapapeles"
101102
},
102103
"menu": {
103104
"trigger": "Chats",

core/http/react-ui/public/locales/it/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"toasts": {
9999
"selectModel": "Seleziona un modello",
100-
"copied": "Copiato negli appunti"
100+
"copied": "Copiato negli appunti",
101+
"copyFailed": "Impossibile copiare negli appunti"
101102
},
102103
"menu": {
103104
"trigger": "Chat",

core/http/react-ui/public/locales/zh-CN/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@
9797
},
9898
"toasts": {
9999
"selectModel": "请选择一个模型",
100-
"copied": "已复制到剪贴板"
100+
"copied": "已复制到剪贴板",
101+
"copyFailed": "无法复制到剪贴板"
101102
},
102103
"menu": {
103104
"trigger": "聊天",

core/http/react-ui/src/components/CanvasPanel.jsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
22
import { renderMarkdown } from '../utils/markdown'
33
import { getArtifactIcon } from '../utils/artifacts'
44
import { safeHref } from '../utils/url'
5+
import { copyToClipboard } from '../utils/clipboard'
56
import DOMPurify from 'dompurify'
67
import hljs from 'highlight.js'
78

@@ -23,11 +24,13 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
2324
}
2425
}, [current, showPreview])
2526

26-
const handleCopy = () => {
27+
const handleCopy = async () => {
2728
const text = current.code || current.url || ''
28-
navigator.clipboard.writeText(text)
29-
setCopySuccess(true)
30-
setTimeout(() => setCopySuccess(false), 2000)
29+
const ok = await copyToClipboard(text)
30+
if (ok) {
31+
setCopySuccess(true)
32+
setTimeout(() => setCopySuccess(false), 2000)
33+
}
3134
}
3235

3336
const handleDownload = () => {

core/http/react-ui/src/hooks/useOperations.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { useState, useEffect, useCallback, useRef } from 'react'
22
import { operationsApi } from '../utils/api'
33
import { useAuth } from '../context/AuthContext'
44

5+
// Serialize ops into a stable comparison key. Each op is a flat map of
6+
// primitives, so JSON.stringify is good enough and stable as long as the
7+
// server emits keys in the same order (Go's map iteration into JSON happens
8+
// to be stable here because we build an explicit map[string]any).
9+
function serializeOps(ops) {
10+
return JSON.stringify(ops)
11+
}
12+
513
export function useOperations(pollInterval = 1000) {
614
const [operations, setOperations] = useState([])
715
const [loading, setLoading] = useState(true)
@@ -11,16 +19,26 @@ export function useOperations(pollInterval = 1000) {
1119

1220
const previousCountRef = useRef(0)
1321
const onAllCompleteRef = useRef(null)
22+
// Track the last payload we wrote into state. Each poll otherwise produces
23+
// a fresh array reference even when nothing changed, and that re-render
24+
// ripples into the Chat page — wiping the user's text selection mid-read
25+
// (#9904).
26+
const lastSerializedRef = useRef('[]')
1427

1528
const fetchOperations = useCallback(async () => {
1629
if (!isAdmin) {
17-
setLoading(false)
30+
setLoading((prev) => (prev ? false : prev))
1831
return
1932
}
2033
try {
2134
const data = await operationsApi.list()
2235
const ops = data?.operations || (Array.isArray(data) ? data : [])
23-
setOperations(ops)
36+
37+
const serialized = serializeOps(ops)
38+
if (serialized !== lastSerializedRef.current) {
39+
lastSerializedRef.current = serialized
40+
setOperations(ops)
41+
}
2442

2543
// Separate active (non-failed) operations from failed ones
2644
const activeOps = ops.filter(op => !op.error)
@@ -32,11 +50,11 @@ export function useOperations(pollInterval = 1000) {
3250
}
3351
previousCountRef.current = activeOps.length
3452

35-
setError(null)
53+
setError((prev) => (prev === null ? prev : null))
3654
} catch (err) {
37-
setError(err.message)
55+
setError((prev) => (prev === err.message ? prev : err.message))
3856
} finally {
39-
setLoading(false)
57+
setLoading((prev) => (prev ? false : prev))
4058
}
4159
}, [isAdmin])
4260

core/http/react-ui/src/pages/AgentChat.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import ResourceCards from '../components/ResourceCards'
99
import ConfirmDialog from '../components/ConfirmDialog'
1010
import { useAgentChat } from '../hooks/useAgentChat'
1111
import { relativeTime } from '../utils/format'
12+
import { copyToClipboard } from '../utils/clipboard'
1213

1314
function getLastMessagePreview(conv) {
1415
if (!conv.messages || conv.messages.length === 0) return ''
@@ -390,9 +391,13 @@ export default function AgentChat() {
390391
}
391392
}
392393

393-
const copyMessage = (content) => {
394-
navigator.clipboard.writeText(content)
395-
addToast('Copied to clipboard', 'success', 2000)
394+
const copyMessage = async (content) => {
395+
const ok = await copyToClipboard(content)
396+
addToast(
397+
ok ? 'Copied to clipboard' : 'Could not copy to clipboard',
398+
ok ? 'success' : 'error',
399+
ok ? 2000 : 3000,
400+
)
396401
}
397402

398403
const senderToRole = (sender) => {

core/http/react-ui/src/pages/Chat.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ChatsMenu from '../components/ChatsMenu'
1717
import { useAuth } from '../context/AuthContext'
1818
import { useOperations } from '../hooks/useOperations'
1919
import { relativeTime } from '../utils/format'
20+
import { copyToClipboard } from '../utils/clipboard'
2021

2122
function getLastMessagePreview(chat) {
2223
if (!chat.history || chat.history.length === 0) return ''
@@ -798,10 +799,14 @@ export default function Chat() {
798799
}
799800
}
800801

801-
const copyMessage = (content) => {
802+
const copyMessage = async (content) => {
802803
const text = typeof content === 'string' ? content : content?.[0]?.text || ''
803-
navigator.clipboard.writeText(text)
804-
addToast(t('toasts.copied'), 'success', 2000)
804+
const ok = await copyToClipboard(text)
805+
if (ok) {
806+
addToast(t('toasts.copied'), 'success', 2000)
807+
} else {
808+
addToast(t('toasts.copyFailed'), 'error', 3000)
809+
}
805810
}
806811

807812
const contextPercent = getContextUsagePercent()

0 commit comments

Comments
 (0)