Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions core/http/react-ui/e2e/chat-polling-selection.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
3 changes: 2 additions & 1 deletion core/http/react-ui/public/locales/de/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion core/http/react-ui/public/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion core/http/react-ui/public/locales/es/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion core/http/react-ui/public/locales/it/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion core/http/react-ui/public/locales/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@
},
"toasts": {
"selectModel": "请选择一个模型",
"copied": "已复制到剪贴板"
"copied": "已复制到剪贴板",
"copyFailed": "无法复制到剪贴板"
},
"menu": {
"trigger": "聊天",
Expand Down
11 changes: 7 additions & 4 deletions core/http/react-ui/src/components/CanvasPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 = () => {
Expand Down
28 changes: 23 additions & 5 deletions core/http/react-ui/src/hooks/useOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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])

Expand Down
11 changes: 8 additions & 3 deletions core/http/react-ui/src/pages/AgentChat.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 8 additions & 3 deletions core/http/react-ui/src/pages/Chat.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading