From 1260d96ef397c9034e239fd633dce6388659f77b Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:23:46 +0800
Subject: [PATCH] fix(chat): close a reader-opened process group from its
height cap
---
.../ui-chat/src/client/chat/ChatGroupSeat.tsx | 10 +++--
.../ui-chat/tests/chat-view.client.spec.tsx | 43 +++++++++++++++++++
2 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx
index 3153d3fdbf..f219a5ae56 100644
--- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx
@@ -176,10 +176,14 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
if (!open) initialize(closed === false ? 'bottom' : 'top')
setOpen(!open)
}, [closed, initialize, open, setOpen])
+ // With next-input timing the height cap applies only to a group the reader opened. A closing body keeps
+ // the cap it was last shown with, so the transition starts from what is on screen: a reader-opened group
+ // closes from its cap, and a flat latest Turn becoming historical closes from its flat height.
+ const shownCap = !grouped ? false : open ? true : undefined
+ const [closingCap, setClosingCap] = useState(shownCap ?? true)
+ if (shownCap !== undefined && shownCap !== closingCap) setClosingCap(shownCap)
if (members === undefined) return null
- // The height cap applies only to a group the reader opened; a body that is closing keeps its flat
- // height so the transition starts from what is on screen instead of snapping to the cap first.
- const capped = grouped && (!deferCompletedTurns || open)
+ const capped = grouped && (!deferCompletedTurns || (shownCap ?? closingCap))
const classes = [css.body, !capped ? css.expandedBody : '',
capped && edges.canScrollUp ? css.fadeTop : '', capped && edges.canScrollDown ? css.fadeBottom : '']
return (
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 e7db8eefb4..0931292539 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -974,6 +974,49 @@ describe('ChatView', () => {
},
)
+ // jsdom applies no CSS, so the cap is observed through the class that carries `max-height`; the fold
+ // animation measures the body in the commit that closes it.
+ const uncapped = (body: HTMLElement): boolean => body.className.includes('expandedBody')
+
+ it.each(['compact', 'standard'] as const)(
+ 'closes a reader-opened running %s group from its height cap with next-input timing', (mode) => {
+ const h = makeCompletionHarness(mode, 'next-input')
+ const view = render()
+ const root = view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')!
+ const header = root.querySelector('[data-process-activity]')!
+ const body = root.querySelector('[data-step-process-body]')!
+ fireEvent.click(header)
+ expect(body.hasAttribute('hidden')).toBe(false)
+ expect(uncapped(body)).toBe(false)
+ fireEvent.click(header)
+ expect(header.getAttribute('aria-expanded')).toBe('false')
+ expect(body.hasAttribute('hidden')).toBe(true)
+ expect(uncapped(body)).toBe(false)
+ },
+ )
+
+ it('closes flat Detailed rows at full height and a reader-opened historical group from its cap', () => {
+ const h = makeCompletionHarness('detailed', 'next-input')
+ const view = render()
+ act(() => { h.complete() })
+ const root = view.container.querySelector('[data-chat-group-key][data-chat-turn="2"]')!
+ const header = root.querySelector('[data-process-activity]')!
+ const body = root.querySelector('[data-step-process-body]')!
+ expect(body.hasAttribute('hidden')).toBe(false)
+ expect(uncapped(body)).toBe(true)
+ act(() => { h.sendNext() })
+ act(() => { h.startNext() })
+ expect(body.hasAttribute('hidden')).toBe(true)
+ expect(uncapped(body)).toBe(true)
+ fireEvent.click(view.container.querySelector('[data-turn-process="2"]')!)
+ fireEvent.click(header)
+ expect(body.hasAttribute('hidden')).toBe(false)
+ expect(uncapped(body)).toBe(false)
+ fireEvent.click(header)
+ expect(body.hasAttribute('hidden')).toBe(true)
+ expect(uncapped(body)).toBe(false)
+ })
+
it.each([
{ initialHeight: 200, closed: true },
{ initialHeight: 600, closed: true },