diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3a59ce8e2c..73ff226ebe 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 777cc09732..689fb0511b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -73,12 +73,14 @@ Work-details modes control process-group display and reasoning previews; eligibl ## 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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index d5490216e3..3cfe383e93 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -73,12 +73,14 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通 ## 分组渲染 -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,也不改变钩子引用。展示模式切换保留展开状态。 diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 4597238c66..84bbc545fc 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -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) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f24b77acd4..5fec4be8e3 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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() + class Observer implements ResizeObserver { + readonly targets = new Set() + 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() + 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() + const header = view.container.querySelector('[data-process-activity]')! + const body = view.container.querySelector('[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() + const groups = snapshot.order.map((nodeKey, index): GroupSnapshot => ({ + 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() + 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)