fix(client): isolate JSON copy feedback and guard string measurement

This commit is contained in:
_Kerman committed 2026-09-09 19:52:42 +08:00
1 parent 573b044320
commit e7fc4e8fdc
5 files changed
+254 -99

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: d783a2d4eaaac887c5357fe30efc5e47249066be
README.zh.md: 33dc17db52e7263aa6aab81549a3b95f7dbfc59d
README.md: 888662a9e1150b3cf410f2f8202d0f27cba0e961
README.zh.md: c1d5a4c45ff364925bb66506c0ea1bf8dae0df5d
+2
View File
@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
### Localizing copy
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
@@ -74,6 +74,8 @@ kind: "package-library"
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
+139 -88
View File
@@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
@@ -65,6 +65,105 @@ interface RowTarget {
value: unknown
}
type CopyMode = 'json' | 'path' | 'prettyJson' | 'value'
interface CopySnapshot {
id: string
target: RowTarget
state: 'idle' | 'copied' | 'failed'
menuOpen: boolean
}
/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */
function createCopyStore() {
let current: CopySnapshot | undefined
const listeners = new Map<string, Set<() => void>>()
return {
get: () => current,
set(next: CopySnapshot | undefined) {
const previous = current?.id
current = next
for (const id of new Set([previous, next?.id])) {
if (id === undefined) continue
for (const listener of listeners.get(id) ?? []) listener()
}
},
subscribe(id: string, listener: () => void) {
let row = listeners.get(id)
if (row === undefined) listeners.set(id, row = new Set())
row.add(listener)
return () => {
row.delete(listener)
if (row.size === 0) listeners.delete(id)
}
},
}
}
function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: {
store: ReturnType<typeof createCopyStore>
target: RowTarget
persistent: boolean
labels: JsonTreeLabels
onCopy: (target: RowTarget, mode: CopyMode) => Promise<void>
onClose: () => void
}) {
const id = pathId(target.path)
const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store])
const getSnapshot = () => {
const current = store.get()
return current?.id === id ? current : undefined
}
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const buttonRef = useRef<HTMLButtonElement>(null)
const state = snapshot?.state ?? 'idle'
const object = typeof target.value === 'object' && target.value !== null
const copyTitle = state === 'copied'
? labels.copied
: state === 'failed'
? labels.copyFailed
: object ? labels.copyPrettyJson : labels.copyValue
return (
<span className={css.copySlot}>
{(persistent || snapshot !== undefined) && (
<Menu
open={snapshot?.menuOpen === true}
compact
portal
align="end"
anchor={(
<button
ref={buttonRef}
type="button"
className={css.actionButton}
data-json-copy-button
data-state={state}
aria-label={copyTitle}
title={labels.copyButtonTitle(copyTitle)}
onClick={() => void onCopy(target, object ? 'prettyJson' : 'value')}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
store.set({ id, target, state, menuOpen: true })
}}
>
{state === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
onSelect={(mode) => {
void onCopy(target, mode as CopyMode)
}}
onClose={onClose}
getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
/>
)}
</span>
)
}
function isExpandableValue(value: unknown): value is object | unknown[] {
return typeof value === 'object' && value !== null && !(value instanceof Date)
}
@@ -273,6 +372,8 @@ function JsonString({
useLayoutEffect(() => {
if (!expanded) return
const raw = rawRef.current as HTMLPreElement
// Keep raw text within the window and clipping ancestors outside the tree.
// Capture scrolling because an ancestor can move the string without resizing it.
const clips: HTMLElement[] = []
const tree = raw.closest<HTMLElement>(`.${css.root}`) as HTMLElement
for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) {
@@ -292,13 +393,13 @@ function JsonString({
raw.style.maxHeight = `${Math.max(16, available - 4)}px`
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(raw)
for (const clip of clips) observer.observe(clip)
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
observer?.observe(raw)
for (const clip of clips) observer?.observe(clip)
window.addEventListener('resize', measure)
window.addEventListener('scroll', measure, true)
return () => {
observer.disconnect()
observer?.disconnect()
window.removeEventListener('resize', measure)
window.removeEventListener('scroll', measure, true)
}
@@ -319,6 +420,7 @@ function JsonString({
>
{value}
</pre>
{!lastElement && <span className={css.punctuation}>,</span>}
<div className={css.stringActions}>
{stringWrapping !== undefined && (
<button
@@ -539,7 +641,7 @@ function formattedPath(path: JsonPath): string {
}, '$')
}
function copyText(target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
function copyText(target: RowTarget, mode: CopyMode): string {
if (mode === 'path') return formattedPath(target.path)
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
if (mode === 'json') return JSON.stringify(target.value)
@@ -604,12 +706,9 @@ export function JsonTree({
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
const activeRowRef = useRef<HTMLElement>()
const copyButtonRef = useRef<HTMLButtonElement | null>(null)
const copyMenuOpenRef = useRef(false)
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
const [copyTarget, setCopyTarget] = useState<RowTarget>()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
const copySequence = useRef(0)
const [copyStore] = useState(createCopyStore)
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
const setActiveRow = (row: HTMLElement | undefined) => {
@@ -619,113 +718,65 @@ export function JsonTree({
}
const clearCopyTarget = () => {
copySequence.current += 1
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
setActiveRow(undefined)
setCopyTarget(undefined)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
copyStore.set(undefined)
}
useEffect(() => () => {
copySequence.current += 1
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
activeRowRef.current?.removeAttribute('data-json-copy-active')
}, [])
useEffect(() => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = undefined
copyMenuOpenRef.current = false
setCopyTarget(undefined)
setCopyState('idle')
setCopyMenuOpen(false)
clearCopyTarget()
setTabStopId(initialTabStopId)
}, [data, expandTopLevel, initialTabStopId])
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
if (!copyable || copyMenuOpenRef.current) return
if (!copyable || copyStore.get()?.menuOpen) return
if (activeRowRef.current === row) return
setActiveRow(row)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
setCopyTarget(target)
copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false })
}
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!copyable || copyMenuOpenRef.current) return
if (!copyable || copyStore.get()?.menuOpen) return
/* v8 ignore next -- browser mouse events delivered through React target an Element. */
if (!(event.target instanceof Element)) return
if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
}
const copy = async (target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value') => {
setCopyTarget(target)
const copy = async (target: RowTarget, mode: CopyMode) => {
const sequence = ++copySequence.current
const snapshot: CopySnapshot = {
id: pathId(target.path), target, state: 'idle', menuOpen: false,
}
copyStore.set(snapshot)
let state: CopySnapshot['state']
try {
await navigator.clipboard.writeText(copyText(target, mode))
setCopyState('copied')
state = 'copied'
} catch {
setCopyState('failed')
state = 'failed'
}
const current = copyStore.get()
if (sequence !== copySequence.current || current?.target !== target) return
copyStore.set({ ...current, state })
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
resetTimer.current = setTimeout(() => {
const current = copyStore.get()
if (current?.target === target) copyStore.set({ ...current, state: 'idle' })
}, 1_500)
}
const [rootOpen, rootClose] = bracketOf(data)
const renderCopy = copyable ? (target: RowTarget, persistent = false) => {
const active = copyTarget !== undefined && pathId(copyTarget.path) === pathId(target.path)
const state = active ? copyState : 'idle'
const object = typeof target.value === 'object' && target.value !== null
const copyTitle = state === 'copied'
? labels.copied
: state === 'failed'
? labels.copyFailed
: object ? labels.copyPrettyJson : labels.copyValue
return (
<span className={css.copySlot}>
{(persistent || active) && (
<Menu
open={active && copyMenuOpen}
compact
portal
align="end"
anchor={(
<button
type="button"
className={css.actionButton}
data-json-copy-button
data-state={state}
aria-label={copyTitle}
title={labels.copyButtonTitle(copyTitle)}
onClick={() => void copy(target, object ? 'prettyJson' : 'value')}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
copyButtonRef.current = event.currentTarget
setCopyTarget(target)
copyMenuOpenRef.current = true
setCopyMenuOpen(true)
}}
>
{state === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
onSelect={(id) => {
void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}}
onClose={clearCopyTarget}
getAnchorRect={() => (
copyButtonRef.current as HTMLButtonElement
).getBoundingClientRect()}
/>
)}
</span>
)
} : undefined
const renderCopy = copyable ? (target: RowTarget, persistent = false) => (
<JsonCopyAction store={copyStore} target={target} persistent={persistent} labels={labels}
onCopy={copy} onClose={clearCopyTarget} />
) : undefined
return (
<div
@@ -733,7 +784,7 @@ export function JsonTree({
style={{ '--json-tree-collapsed-lines': collapsedStringLines } as CSSProperties}
onMouseOver={handleRootMouseOver}
onMouseLeave={() => {
if (!copyMenuOpenRef.current) clearCopyTarget()
if (!copyStore.get()?.menuOpen) clearCopyTarget()
}}
>
{expandTopLevel
@@ -12,12 +12,12 @@ function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label'
return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
}
let writeText: ReturnType<typeof vi.fn>
let writeText: ReturnType<typeof vi.fn<Clipboard['writeText']>>
let originalClipboard: PropertyDescriptor | undefined
beforeEach(() => {
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
writeText = vi.fn().mockResolvedValue(undefined)
writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
@@ -33,15 +33,115 @@ afterEach(() => {
else Object.defineProperty(navigator, 'clipboard', originalClipboard)
})
function stubStringLayout(scrollHeight = 200): void {
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight)
const computedStyle = window.getComputedStyle.bind(window)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
const style = computedStyle(element)
style.lineHeight = '16px'
if (style.paddingBottom === '') style.paddingBottom = '0px'
return style
})
}
describe('JsonTree', () => {
it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200)
const computedStyle = window.getComputedStyle.bind(window)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
const style = computedStyle(element)
style.lineHeight = '16px'
return style
it('ignores clipboard settlement after the row changes or the tree unmounts', async () => {
vi.useFakeTimers()
const pending: (() => void)[] = []
writeText.mockImplementation(() => new Promise<void>((resolve) => { pending.push(resolve) }))
const view = render(<JsonTree data={{ first: 1, second: 2 }} />)
const rows = screen.getAllByRole('treeitem')
fireEvent.mouseOver(rows[0] as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
fireEvent.mouseOver(rows[1] as HTMLElement)
await act(async () => { pending[0]!() })
expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
view.unmount()
const timerCount = vi.getTimerCount()
await act(async () => { pending[1]!() })
expect(vi.getTimerCount()).toBe(timerCount)
})
it('updates copy actions without rereading JSON properties on hover', () => {
const readValue = vi.fn(() => 'payload '.repeat(100))
const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [
`field${index}`,
{ get value() { return readValue() } },
]))
render(<JsonTree data={data} />)
const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
readValue.mockClear()
fireEvent.mouseOver(rows[0] as HTMLElement)
expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
fireEvent.mouseOver(rows[1] as HTMLElement)
expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull()
expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
expect(readValue).not.toHaveBeenCalled()
})
it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => {
stubStringLayout()
vi.stubGlobal('ResizeObserver', undefined)
let rawTop = 150
const view = render(
<div style={{ overflowY: 'auto', paddingBottom: '10px' }}>
<JsonTree data={{ first: 'raw\ntext', last: 'last\ntext' }} />
</div>,
)
const clip = view.container.firstElementChild as HTMLElement
vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2)
vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140)
})
const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
const raw = rows[0]?.querySelector('pre') as HTMLPreElement
expect(raw.textContent).toBe('raw\ntext')
expect(raw.style.maxHeight).toBe('78px')
expect(raw.nextElementSibling?.textContent).toBe(',')
rawTop = 80
fireEvent.scroll(clip)
expect(raw.style.maxHeight).toBe('126px')
rawTop = 300
fireEvent.resize(window)
expect(raw.style.maxHeight).toBe('16px')
fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' }))
expect(raw.isConnected).toBe(false)
rawTop = 100
fireEvent.scroll(clip)
expect(raw.style.maxHeight).toBe('16px')
fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',')
})
it('shows the string expander only beyond the configured collapsed line count', () => {
stubStringLayout(48)
let resize: (() => void) | undefined
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback }
observe() {}
disconnect = disconnect
})
const data = { text: 'three lines of text' }
const view = render(<JsonTree data={data} />)
expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
view.rerender(<JsonTree data={data} collapsedStringLines={2} />)
expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy()
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16)
act(() => { resize?.() })
expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
view.unmount()
expect(disconnect).toHaveBeenCalledTimes(2)
})
it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
stubStringLayout()
vi.stubGlobal('ResizeObserver', class {
observe() {}
disconnect() {}