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)