perf(chat): narrow group title updates and defer edge measurements
This commit is contained in:
1 parent
1bcb633c3f
commit
376f68199e
5 files changed
+105
-6
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-chat/README.md
|
||||
README.md: 777cc097326cb0d7b75e1c7eb9d35e0060d3476a
|
||||
README.zh.md: d5490216e34b62da778a28493285396d89a66a84
|
||||
README.md: 689fb0511b237de899509caab36e15c7f8dd68c8
|
||||
README.zh.md: 3cfe383e93e25b5c87123e4ba3c7b8b1e3553de5
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user