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 },