perf(chat): narrow group title updates and defer edge measurements

This commit is contained in:
imccyu committed 2026-09-22 00:45:45 +08:00
1 parent 1bcb633c3f
commit 376f68199e
5 files changed
+105 -6

No files matched your search

+2 -2
View File
@@ -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-chat/README.md
README.md: 777cc097326cb0d7b75e1c7eb9d35e0060d3476a
README.zh.md: d5490216e34b62da778a28493285396d89a66a84
README.md: 689fb0511b237de899509caab36e15c7f8dd68c8
README.zh.md: 3cfe383e93e25b5c87123e4ba3c7b8b1e3553de5
+3 -1
View File
@@ -73,12 +73,14 @@ Work-details modes control process-group display and reasoning previews; eligibl
<a id="grouped-rendering"></a>
## Grouped rendering
Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries.
Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries.
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition.
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
+3 -1
View File
@@ -73,12 +73,14 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通
<a id="grouped-rendering"></a>
## 分组渲染
Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。
Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
@@ -107,7 +107,7 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG
readonly toggle: () => void
}) {
const data = useChatGroup(groupKey, group => group?.data)
const detailed = usePresentation(policy => policy.liveProcessDetail)
const detailed = usePresentation(policy => data?.closed === false && policy.liveProcessDetail)
const live = useStableLiveProcessTitle({
activity: data?.summary.running ?? 'thinking',
detail: data?.summary.runningDetail ?? '',
@@ -172,7 +172,6 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
}, [bodyRef])
// The content box reports growth even when the scroll body remains height-capped.
useLayoutEffect(() => {
sync()
const body = bodyRef.current
if (body === null || !open || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(sync)
@@ -697,6 +697,102 @@ describe('ChatView', () => {
expect(view.queryByRole('textbox', { name: outside.key })).toBeNull()
})
it('waits for observed layout before measuring an opened process group', () => {
const observers = new Set<Observer>()
class Observer implements ResizeObserver {
readonly targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) { observers.add(this) }
observe(target: Element): void { this.targets.add(target) }
unobserve(target: Element): void { this.targets.delete(target) }
disconnect(): void { observers.delete(this) }
}
vi.stubGlobal('ResizeObserver', Observer)
const snapshot = chatSnapshotFixture({ nodes: [user(1, 'inside')] })
const h = makeHarness({}, {}, snapshot)
const key = 'observed-process' as GroupKey
const groups = new ConversationGroupStore<ProcessGroupData>()
groups.prepareAndInstall({
entries: [{ kind: 'group', key }],
groups: { kind: 'replace', snapshots: [{
key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }],
data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } },
}] },
}, key => snapshot.nodes.get(key))
h.setGrouped(groups)
const view = render(<h.ChatView {...h.props} />)
const header = view.container.querySelector<HTMLButtonElement>('[data-process-activity]')!
const body = view.container.querySelector<HTMLElement>('[data-step-process-body]')!
let reads = 0
let top = 0
Object.defineProperties(body, {
scrollTop: { get: () => { reads++; return top } },
clientHeight: { get: () => { reads++; return 200 } },
scrollHeight: { get: () => { reads++; return 600 } },
})
fireEvent.click(header)
expect(reads).toBe(0)
const observer = [...observers].find(observer => observer.targets.has(body))!
expect(observer).toBeDefined()
act(() => { observer.callback([], observer) })
expect(body.hasAttribute('data-scroll-up')).toBe(false)
expect(body.hasAttribute('data-scroll-down')).toBe(true)
top = 400
fireEvent.scroll(body)
expect(body.hasAttribute('data-scroll-up')).toBe(true)
expect(body.hasAttribute('data-scroll-down')).toBe(false)
const measured = reads
fireEvent.click(header)
expect(reads).toBe(measured)
expect(observers.has(observer)).toBe(false)
})
it('does not recalculate settled group titles when work-details mode changes', () => {
const snapshot = chatSnapshotFixture({ nodes: Array.from({ length: 41 }, (_, index) => user(index + 1, `member ${index}`)) })
const h = makeHarness({}, {}, snapshot)
const groupStore = new ConversationGroupStore<ProcessGroupData>()
const groups = snapshot.order.map((nodeKey, index): GroupSnapshot<ProcessGroupData> => ({
key: `settled-${index}` as GroupKey,
data: {
turn: 1, closed: index < 40,
summary: { counts: [], running: index < 40 ? undefined : 'commands', runningDetail: index < 40 ? '' : 'pwd' },
},
members: [{ kind: 'node', key: nodeKey as NodeKey }],
}))
groupStore.prepareAndInstall({
entries: groups.map(group => ({ kind: 'group', key: group.key })),
groups: { kind: 'replace', snapshots: groups },
}, key => snapshot.nodes.get(key))
h.setGrouped(groupStore)
const translate = vi.fn(h.props.t)
const view = render(<h.ChatView {...h.props} t={translate} />)
const allHeaders = [...view.container.querySelectorAll('[data-process-activity]')]
const headers = allHeaders.slice(0, 40)
expect(headers).toHaveLength(40)
const titles = headers.map(header => header.textContent)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
translate.mockClear()
act(() => { h.setTranscriptView(mode) })
expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0)
expect([...view.container.querySelectorAll('[data-process-activity]')]).toEqual(allHeaders)
expect(headers.map(header => header.textContent)).toEqual(titles)
const liveLabel = h.props.t('message.stepProcess.commands')
expect(allHeaders[40]?.textContent).toBe(mode === 'compact' ? liveLabel
: `${liveLabel}${h.props.t('message.turnProcess.separator')}pwd`)
}
const running = groups[40]!
act(() => {
groupStore.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [{
...running, data: { ...running.data, closed: true },
}] } }, key => snapshot.nodes.get(key))
groupStore.publish()
})
translate.mockClear()
act(() => { h.setTranscriptView('detailed') })
expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0)
})
it('passes independently keyed group parts to business Node renderers', () => {
const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] })
const h = makeHarness({}, {}, snapshot)