diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 3954654d724..7f6db2161f2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -13054,6 +13054,7 @@ export function App({ const messageListContent = ( { const { createContext } = await import('react'); @@ -243,7 +244,7 @@ describe('MessageItem tool group spacing', () => { const synthetic = render( - {item(toolMsg('summary-agent-1'))} + {item(toolMsg(summaryRunId('agent-1')))} , ); diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index c768988dd9d..bd2d4aa41a5 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -18,6 +18,7 @@ import { } from './messages/AssistantMessage'; import { SystemMessage } from './messages/SystemMessage'; import { ToolGroup } from './messages/ToolGroup'; +import { isSummaryRunId } from './summaryRunId'; import { PlanMessage } from './messages/PlanMessage'; import { BtwMessage } from './messages/BtwMessage'; import { UserShellMessage } from './messages/UserShellMessage'; @@ -123,7 +124,7 @@ export const MessageItem = memo(function MessageItem({ ({ variant: 'warning', source: 'prompt_cancelled', }); +const recapMsg = (id: string): SystemMessage => ({ + id, + role: 'system', + content: 'Recap: earlier work', + variant: 'info', + source: 'recap', +}); const backgroundNotificationMsg = ( id: string, toolUseId?: string, @@ -319,6 +326,7 @@ function mount( historyCapacityReached?: boolean; historyPaginationError?: boolean; onLoadOlderHistory?: (options?: { force?: boolean }) => Promise; + sessionKey?: string; transcriptBlockCount?: number; transcriptActivity?: { getSnapshot(): { @@ -370,6 +378,7 @@ function mount( historyCapacityReached={opts.historyCapacityReached} historyPaginationError={opts.historyPaginationError} onLoadOlderHistory={opts.onLoadOlderHistory} + sessionKey={opts.sessionKey} transcriptBlockCount={opts.transcriptBlockCount} transcriptActivity={opts.transcriptActivity} onReloadTranscript={opts.onReloadTranscript} @@ -406,6 +415,9 @@ function rerenderMessages( loadingTranscript?: boolean; catchingUp?: boolean; isResponding?: boolean; + hasOlderHistory?: boolean; + onLoadOlderHistory?: (options?: { force?: boolean }) => Promise; + sessionKey?: string; } = {}, ): void { const entry = mounted.find((item) => item.container === container); @@ -422,6 +434,9 @@ function rerenderMessages( loadingTranscript={opts.loadingTranscript} catchingUp={opts.catchingUp} isResponding={opts.isResponding} + hasOlderHistory={opts.hasOlderHistory} + onLoadOlderHistory={opts.onLoadOlderHistory} + sessionKey={opts.sessionKey} /> @@ -1017,6 +1032,1661 @@ describe('MessageList — turn collapse (DOM)', () => { expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); }); + it('keeps a turn expanded when pagination completes its head after its tail was shown', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + // The daemon split turn B: only its tail (t1/a1) is loaded; turn C is + // complete in the window, so the tail renders as pre-prompt passthrough. + const c = mount( + [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 't1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // The next page brings turn B's head. The turn must stay expanded instead + // of collapsing the steps the user is already reading. + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + // The unrelated complete turn still collapses as usual. + expect(isCollapsed(c, 't2')).toBe(true); + }); + + it('still collapses a turn whose head arrives in one complete page', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const c = mount( + [userMsg('u2'), thinkingMsg('t2'), asstMsg('a2')], + undefined, + { hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + // A head that arrives together with its whole turn was never shown + // before, so the default collapse behavior applies unchanged. + expect(has(c, 'u1')).toBe(true); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + }); + + it('lets an explicit toggle re-collapse a pagination-expanded turn for good', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const c = mount( + [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + const completed = [ + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + rerenderMessages(c, completed, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(has(c, 't1')).toBe(true); + + // The user collapses the turn: the toggle must stick across later + // re-renders instead of the pagination keep-open re-asserting itself. + click(toggle(c, 'u1')); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + + rerenderMessages(c, completed, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(isCollapsed(c, 't1')).toBe(true); + }); + + it('re-expands the anchored turn when its collapse hides the anchor row', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + if (rowKey === 'msg:u1') return rect(800, 50, 50); + if (rowKey === 'msg:t1') return rect(800, 50, 100); + if (rowKey === 'msg:a1') return rect(800, 50, 150); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + const messages = [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')]; + // The turn is live (expanded) while a history page is requested; the + // anchor captures the topmost visible message row (t1). + const c = mount(messages, undefined, { + isResponding: true, + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 't1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + try { + // The session ends while the page is still in flight: the turn + // collapses and the anchored row disappears. + rerenderMessages(c, messages, { isResponding: false }); + expect(has(c, 't1')).toBe(true); + + // The anchor restore re-expands the turn instead of dropping the + // anchor, so the reader keeps their position and the content. + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + await nextFrame(); + expect(has(c, 't1')).toBe(true); + } finally { + rectSpy.mockRestore(); + } + }); + + it('drops the anchor instead of re-expanding when the user collapsed the anchored turn', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + if (rowKey === 'msg:u1') return rect(800, 50, 50); + if (rowKey === 'msg:t1') return rect(800, 50, 100); + if (rowKey === 'msg:a1') return rect(800, 50, 150); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + const tail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const completed = [userMsg('u1'), ...tail]; + const c = mount(tail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + try { + // Page 1 completes the split turn's head: the keep-open expands it. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + rerenderMessages(c, completed, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + await nextFrame(); + expect(has(c, 't1')).toBe(true); + + // Page 2 anchors on the now-visible t1 row while the fetch is in flight. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + + // The user collapses the turn before the page commits. + click(toggle(c, 'u1')); + expect(isCollapsed(c, 't1')).toBe(true); + + // The commit lands: the explicit collapse wins over the keep-open, so + // the fallback must drop the anchor instead of re-expanding... + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + await nextFrame(); + expect(isCollapsed(c, 't1')).toBe(true); + + // ...and pagination is not stuck: a third load still fires. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(3); + + // The superseded load's snapshot must not wedge later detection: page 3 + // lands mid-turn... + rerenderMessages(c, [thinkingMsg('tC1'), asstMsg('aC1'), ...completed], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + // ...page 4 then completes that turn's head while its tail is already + // on screen, so it stays expanded. + // Re-top the container: re-renders snap it to the bottom while + // following; the scroll event must start near the top to trigger. + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(4); + rerenderMessages( + c, + [userMsg('uC'), thinkingMsg('tC1'), asstMsg('aC1'), ...completed], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'tC1')).toBe(true); + expect(toggleRow(c, 'uC').getAttribute('aria-expanded')).toBe('true'); + } finally { + rectSpy.mockRestore(); + } + }); + + it('keeps split-turn detection alive when a superseded load fails', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + if (rowKey === 'msg:u1') return rect(800, 50, 50); + if (rowKey === 'msg:t1') return rect(800, 50, 100); + if (rowKey === 'msg:a1') return rect(800, 50, 150); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let rejectLoad!: (error: Error) => void; + const onLoadOlderHistory = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectLoad = reject; + }), + ) + .mockResolvedValue(undefined); + const tail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const completed = [userMsg('u1'), ...tail]; + const c = mount(tail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + try { + // Page 1 completes the split turn's head; the keep-open expands it. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + rerenderMessages(c, completed, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + + // Page 2 anchors on the visible t1 row; the user collapses the turn + // before the fetch settles, superseding the load. + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + click(toggle(c, 'u1')); + expect(isCollapsed(c, 't1')).toBe(true); + + // The superseded load fails: its snapshot must still be dropped. + await act(async () => { + rejectLoad(new Error('load failed')); + await Promise.resolve(); + }); + + // Page 3 lands mid-turn... + rerenderMessages(c, [thinkingMsg('tC1'), asstMsg('aC1'), ...completed], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + // ...and page 4 completes turn uC, whose tail page 3 showed: it stays + // expanded instead of collapsing mid-read behind the orphan snapshot. + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(3); + rerenderMessages( + c, + [userMsg('uC'), thinkingMsg('tC1'), asstMsg('aC1'), ...completed], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'tC1')).toBe(true); + expect(toggleRow(c, 'uC').getAttribute('aria-expanded')).toBe('true'); + } finally { + rectSpy.mockRestore(); + } + }); + + it('resets pagination state on a direct session switch without empty messages', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + if (rowKey === 'msg:u1') return rect(800, 50, 50); + if (rowKey === 'msg:t1') return rect(800, 50, 100); + if (rowKey === 'msg:a1') return rect(800, 50, 150); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const tail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(tail, undefined, { + sessionKey: 'session-a', + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + try { + // Page 1 completes the split turn's head: a keep-open entry for u1. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + rerenderMessages(c, [userMsg('u1'), ...tail], { + sessionKey: 'session-a', + hasOlderHistory: true, + onLoadOlderHistory, + }); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + + // The user expands turn u2 explicitly: an override entry. + click(toggle(c, 'u2')); + expect(has(c, 't2')).toBe(true); + + // Load 2 flies with an anchor on the visible t1 row. + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + + // Direct switch to session B, which reuses the same ids, with no empty + // render in between and load 2 still pending: B's complete turns + // collapse by default — neither A's keep-open entry, nor A's override, + // nor A's orphaned anchor may force them open. + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { sessionKey: 'session-b', hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + expect(isCollapsed(c, 't2')).toBe(true); + expect(toggleRow(c, 'u2').getAttribute('aria-expanded')).toBe('false'); + } finally { + rectSpy.mockRestore(); + } + }); + + it('lets the next session paginate after switching away from an in-flight load', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const resolvers: Array<() => void> = []; + const onLoadOlderHistory = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const sessionATail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(sessionATail, undefined, { + sessionKey: 'session-a', + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + // Session A's load never settles: its snapshot is pending at switch time. + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // Direct switch to session B with distinct ids and no empty intermediate. + rerenderMessages(c, [userMsg('u5'), thinkingMsg('t5'), asstMsg('a5')], { + sessionKey: 'session-b', + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(isCollapsed(c, 't5')).toBe(true); + + // B paginates: page 1 lands mid-turn u4... + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + await act(async () => { + resolvers[1]?.(); + await Promise.resolve(); + }); + rerenderMessages( + c, + [ + thinkingMsg('t4'), + asstMsg('a4'), + userMsg('u5'), + thinkingMsg('t5'), + asstMsg('a5'), + ], + { sessionKey: 'session-b', hasOlderHistory: true, onLoadOlderHistory }, + ); + + // ...page 2 completes u4's head while its tail is on screen: the turn + // must stay expanded, detected through B's own snapshot. + list.scrollTop = 0; + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(3); + await act(async () => { + resolvers[2]?.(); + await Promise.resolve(); + }); + rerenderMessages( + c, + [ + userMsg('u4'), + thinkingMsg('t4'), + asstMsg('a4'), + userMsg('u5'), + thinkingMsg('t5'), + asstMsg('a5'), + ], + { sessionKey: 'session-b', hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 't4')).toBe(true); + expect(toggleRow(c, 'u4').getAttribute('aria-expanded')).toBe('true'); + }); + + it('does not block the next session when the previous load fails after a switch', async () => { + let scrollHeight = 1200; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => scrollHeight, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rejects: Array<(error: Error) => void> = []; + const onLoadOlderHistory = vi.fn( + () => + new Promise((_, reject) => { + rejects.push(reject); + }), + ); + const c = mount( + [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { sessionKey: 'session-a', hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // Switch while A's load is still in flight. + rerenderMessages(c, [userMsg('u5'), thinkingMsg('t5'), asstMsg('a5')], { + sessionKey: 'session-b', + hasOlderHistory: true, + onLoadOlderHistory, + }); + // A's load fails only after the switch: its stale failure must not + // retry-block the new session's pagination. + await act(async () => { + rejects[0]?.(new Error('session A load failed')); + await Promise.resolve(); + }); + + // B's transcript is short: pagination is underfill-driven. A live update + // re-renders, and the auto-load must fire. + scrollHeight = 600; + rerenderMessages( + c, + [userMsg('u5'), thinkingMsg('t5'), asstMsg('a5'), asstMsg('a5b')], + { sessionKey: 'session-b', hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + }); + + it('clears the retry block of a failed previous-session load on switch', async () => { + let scrollHeight = 1200; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => scrollHeight, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi + .fn() + .mockRejectedValueOnce(new Error('load failed')) + .mockResolvedValue(undefined); + const c = mount( + [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { sessionKey: 'session-a', hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // Switch to session B, whose transcript is short (underfill-driven + // pagination): A's failed load must not block B's auto-load. + scrollHeight = 600; + rerenderMessages(c, [userMsg('u5'), thinkingMsg('t5'), asstMsg('a5')], { + sessionKey: 'session-b', + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + }); + + it('does not let a live message landing mid-fetch consume the split-turn detection', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + const tail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(tail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // A live message appends while the page is in flight; it must not + // consume the detection snapshot (the head has not changed yet). + rerenderMessages(c, [...tail, asstMsg('live')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + + // The page then commits the split turn's head; detection still fires and + // keeps the turn expanded. + rerenderMessages(c, [userMsg('u1'), ...tail, asstMsg('live')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + }); + + it('keeps the snapshot across a non-pagination head change mid-fetch', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + const tail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(tail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // A non-pagination head change (transcript reload / session switch with + // fresh ids) lands while the fetch is in flight; it must NOT consume the + // snapshot, or the page commit below would silently skip detection. + rerenderMessages(c, [userMsg('x1'), thinkingMsg('x2'), asstMsg('x3')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + + // The real page then commits the split turn's head; detection still + // fires because the snapshot survived. + rerenderMessages(c, [userMsg('u1'), ...tail], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + }); + + it('keeps split-turn detection alive when a recap message is pinned at the head', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const recap = recapMsg('local-recap-1'); + // A /recap issued after Ctrl+L pins a local recap message at index 0; + // pagination prepends cannot move it. The daemon split turn u1: only its + // tail (t1/a1) is loaded, plus a complete turn u2 after it. + const c = mount( + [ + recap, + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 't1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // The page completes the split turn's head, landing right after the + // pinned recap; the recap stays at index 0. + rerenderMessages( + c, + [ + recap, + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + }); + + it('clears the pagination snapshot and keep-open set on /clear so the next session is not mislabeled', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + // Session A tail; the block ids below are reused verbatim by session B, + // mirroring the daemon's per-session ordinal id scheme. + const sessionATail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(sessionATail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + + // The page completes the split turn's head first, so the keep-open set + // holds an entry by the time the screen clears. + rerenderMessages(c, [userMsg('u1'), ...sessionATail], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + + // /clear must drop the snapshot and the keep-open entry alike, or the + // entry leaks into the next session. + rerenderMessages(c, [], { hasOlderHistory: true, onLoadOlderHistory }); + + // Session B arrives with a complete, never-split turn that reuses the + // same ids; it must collapse by default, not stay expanded off a stale + // pre-clear snapshot. + rerenderMessages(c, [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(has(c, 'u1')).toBe(true); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the pagination-completed turn expanded while the tail streams', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const streamingTail = (content: string): AssistantMessage => ({ + ...asstMsg('a2'), + content, + isStreaming: true, + }); + const tail: Message[] = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + streamingTail('partial'), + ]; + const c = mount(tail, undefined, { + isResponding: true, + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // The page commits while the tail is still streaming. The head object is + // reused verbatim by the later snapshots, mirroring how the daemon reuses + // message identities and only replaces the streaming tail. + const head = userMsg('u1'); + rerenderMessages(c, [head, ...tail], { + isResponding: true, + hasOlderHistory: true, + onLoadOlderHistory, + }); + + // Streaming keeps updating the tail over the same messages; the keep-open + // added by the detection must not be masked by the streaming-tail + // fast-path cache serving the page-commit render's collapsed value. + rerenderMessages( + c, + [head, ...tail.slice(0, -1), streamingTail('partial answer')], + { isResponding: true, hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + }); + + it('keeps a split turn expanded in compact mode when the page extends its aggregated run', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + // Interrupted (assistant-less) turn: only its thinking/tool tail fragment + // is loaded, aggregated into one summary row in compact mode, plus a + // newer complete turn. + const c = mount( + [ + thinkingMsg('t1'), + toolMsg('g1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { compactMode: true, hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + // Sanity: aggregation is live — the fragment renders as one summary row. + expect(has(c, 'summary-t1')).toBe(true); + expect(has(c, 't1')).toBe(false); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // The page prepends the turn's head and extends the aggregated run + // (summary-t1 re-keys to summary-t0); the turn the user is reading must + // stay expanded. + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t0'), + toolMsg('g0'), + thinkingMsg('t1'), + toolMsg('g1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'u1')).toBe(true); + expect(has(c, 'summary-t0')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + }); + + it('re-expands the anchored turn through a compact aggregate row key', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + if (rowKey === 'msg:u1') return rect(800, 50, 50); + if (rowKey === 'msg:summary-t1') return rect(800, 50, 100); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ); + const messages = [userMsg('u1'), thinkingMsg('t1'), toolMsg('g1')]; + // Compact mode aggregates the run into one row; the anchor captures it. + const c = mount(messages, undefined, { + compactMode: true, + isResponding: true, + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 'summary-t1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + try { + // The response completes while the page is still in flight: the turn + // collapses and the anchored aggregate row disappears. + rerenderMessages(c, [...messages, asstMsg('a1')], { + isResponding: false, + }); + + // The anchor restore resolves the aggregate row key and re-expands the + // turn instead of dropping the anchor. + expect(has(c, 'summary-t1')).toBe(true); + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + await nextFrame(); + expect(has(c, 'summary-t1')).toBe(true); + } finally { + rectSpy.mockRestore(); + } + }); + + it('restores the scroll position when the page extends the anchored compact run', async () => { + let scrollHeight = 1200; + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => scrollHeight, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const rect = ( + width: number, + height: number, + top: number, + left = 0, + ): DOMRect => ({ + width, + height, + top, + right: left + width, + bottom: top + height, + left, + x: left, + y: top, + toJSON: () => ({}), + }); + // After the page lands, the prepended history pushes the anchored turn's + // rows down while the scroll position has not followed yet. + let shifted = false; + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const rowKey = this.getAttribute('data-message-row-key'); + const shift = shifted ? 600 : 0; + if (rowKey === 'msg:summary-t1') return rect(800, 50, 100); + if (rowKey === 'msg:u1') return rect(800, 50, 100 + shift); + if (rowKey === 'msg:summary-t0') return rect(800, 50, 150 + shift); + if (this.hasAttribute('data-web-shell-message-list')) { + return rect(800, 600, 100); + } + return rect(800, 50, 0); + }); + let resolveLoad!: () => void; + const onLoadOlderHistory = vi.fn( + () => + new Promise((resolve) => { + resolveLoad = () => { + scrollHeight = 1800; + resolve(); + }; + }), + ); + // The daemon split turn u1 across pages: only its tail (t1/g1) is loaded, + // aggregated into one summary row in compact mode, before a newer turn. + const c = mount( + [ + thinkingMsg('t1'), + toolMsg('g1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { compactMode: true, hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 'summary-t1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + try { + // The page completes the split turn and extends its aggregated run: the + // summary row re-keys from summary-t1 to summary-t0 while the anchor + // still holds the captured key. + shifted = true; + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t0'), + toolMsg('g0'), + thinkingMsg('t1'), + toolMsg('g1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'summary-t0')).toBe(true); + + await act(async () => { + resolveLoad(); + await Promise.resolve(); + }); + await nextFrame(); + // The keep-open re-expanded the turn, and the anchor restore followed + // the re-keyed run to a visible row instead of dropping: the scroll + // position moved with the prepended history. + expect(has(c, 'summary-t0')).toBe(true); + expect(list.scrollTop).toBe(600); + } finally { + rectSpy.mockRestore(); + } + }); + + it('resets the pagination snapshot when a history load fails', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi + .fn() + .mockRejectedValueOnce(new Error('load failed')); + const sessionATail = [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ]; + const c = mount(sessionATail, undefined, { + hasOlderHistory: true, + onLoadOlderHistory, + }); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // Session B reuses the tail ids in a complete, never-split turn: it must + // collapse by default, not stay expanded off the failed load's snapshot. + rerenderMessages(c, [userMsg('u1'), thinkingMsg('t1'), asstMsg('a1')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(has(c, 'u1')).toBe(true); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + }); + + it('consumes the pagination snapshot when the page completes no split turn', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const c = mount( + [userMsg('u2'), thinkingMsg('t2'), asstMsg('a2')], + undefined, + { + hasOlderHistory: true, + onLoadOlderHistory, + }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // The page lands a whole turn at once: no split turn, so nothing is + // marked keep-open and the snapshot is consumed. + rerenderMessages( + c, + [ + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(isCollapsed(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false'); + + // A later head change must not be compared against the stale snapshot: + // reused tail ids alone cannot force a turn open. + rerenderMessages(c, [userMsg('u3'), thinkingMsg('t2'), asstMsg('a2')], { + hasOlderHistory: true, + onLoadOlderHistory, + }); + expect(isCollapsed(c, 't2')).toBe(true); + expect(toggleRow(c, 'u3').getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps a later page split turn expanded when its load raced the earlier page commit', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 1200, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const resolvers: Array<() => void> = []; + const onLoadOlderHistory = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + // Turns uC and u1 are both split across pages: only turn u1's tail + // (t1/a1) is loaded, plus a complete newer turn. + const c = mount( + [ + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + undefined, + { hasOlderHistory: true, onLoadOlderHistory }, + ); + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + expect(has(c, 't1')).toBe(true); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + + // Load 1 resolves SDK-side before its page commits to the transcript, and + // the anchor effect clears the in-flight flag while the render commit is + // still throttled: a fast scroll-up starts load 2 against the same + // pre-page-1 snapshot. + await act(async () => { + resolvers[0]?.(); + await Promise.resolve(); + }); + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + expect(onLoadOlderHistory).toHaveBeenCalledTimes(2); + + // Page 1 completes turn u1 and lands mid-turn uC, consuming the pending + // detection. + rerenderMessages( + c, + [ + thinkingMsg('tC1'), + asstMsg('aC1'), + thinkingMsg('tC2'), + asstMsg('aC2'), + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 't1')).toBe(true); + expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true'); + expect(has(c, 'tC2')).toBe(true); + + // Page 2 completes turn uC. Its tail was already on screen, so it must + // stay expanded even though its load's snapshot raced page 1's commit. + await act(async () => { + resolvers[1]?.(); + await Promise.resolve(); + }); + rerenderMessages( + c, + [ + userMsg('uC'), + thinkingMsg('tC1'), + asstMsg('aC1'), + thinkingMsg('tC2'), + asstMsg('aC2'), + userMsg('u1'), + thinkingMsg('t1'), + asstMsg('a1'), + userMsg('u2'), + thinkingMsg('t2'), + asstMsg('a2'), + ], + { hasOlderHistory: true, onLoadOlderHistory }, + ); + expect(has(c, 'tC2')).toBe(true); + expect(toggleRow(c, 'uC').getAttribute('aria-expanded')).toBe('true'); + }); + it('keeps latest assistant content when active agents are pinned after it', () => { const activeAgent = agentMsg('agent-1'); activeAgent.tools[0]!.status = 'pending'; diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts index 2091c6028c9..0216e8a8a03 100644 --- a/packages/web-shell/client/components/MessageList.test.ts +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -1133,6 +1133,7 @@ function collapseItems( waitForUnmatchedAgentCompletions: boolean; terminalBackgroundShellTaskIds: ReadonlySet; automaticallyExpandedAgentKeys: ReadonlySet; + paginatedExpanded: ReadonlySet; enabled: boolean; }> = {}, ): DisplayItem[] { @@ -1145,6 +1146,7 @@ function collapseItems( opts.waitForUnmatchedAgentCompletions ?? true, terminalBackgroundShellTaskIds: opts.terminalBackgroundShellTaskIds, automaticallyExpandedAgentKeys: opts.automaticallyExpandedAgentKeys, + paginatedExpanded: opts.paginatedExpanded, enabled: opts.enabled ?? true, }); } @@ -1220,6 +1222,50 @@ describe('applyTurnCollapse', () => { }); }); + it('keeps a turn expanded when its tail was shown before pagination completed its head', () => { + const items = groupParallelAgents([ + makeUserMessage('u1'), + makeMultiToolGroup('g1'), + makeAssistantMessage('a1'), + makeUserMessage('u2'), + makeMultiToolGroup('g2'), + makeAssistantMessage('a2'), + ]); + const out = collapseItems(items, { + paginatedExpanded: new Set(['u1']), + }); + // The pagination-completed turn stays open (its steps are visible); the + // following complete turn still collapses as usual. + expect(rowIds(out)).toEqual([ + 'u1', + 'tc-u1', + 'g1', + 'a1', + 'u2', + 'tc-u2', + 'a2', + ]); + expect(collapseOf(out, 0)).toMatchObject({ + collapsed: false, + hiddenCount: 1, + }); + expect(collapseOf(out, 4)).toMatchObject({ collapsed: true }); + }); + + it('lets an explicit user toggle override the pagination keep-open', () => { + const items = groupParallelAgents([ + makeUserMessage('u1'), + makeMultiToolGroup('g1'), + makeAssistantMessage('a1'), + ]); + const out = collapseItems(items, { + paginatedExpanded: new Set(['u1']), + overrides: new Map([['u1', false]]), + }); + expect(rowIds(out)).toEqual(['u1', 'tc-u1', 'a1']); + expect(collapseOf(out, 0)).toMatchObject({ collapsed: true }); + }); + it('keeps every row but still tags the head when the turn is expanded', () => { const items = groupParallelAgents([ makeUserMessage('u1'), diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index b7f8337a2a4..b7ff415b603 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -42,6 +42,7 @@ import { formatContextTokens } from '../utils/formatTokenCount'; import { useWebShellPortalRoot } from '../portalRoot'; import { useTranscriptRenderMode } from '../transcriptRenderMode'; import { MessageItem } from './MessageItem'; +import { summaryRunFirstMemberId, summaryRunId } from './summaryRunId'; import type { SessionContentGenerator } from './messages/AssistantMessage'; import { MessageTimestamp } from './MessageTimestamp'; import { @@ -88,6 +89,13 @@ interface MessageListProps { historyCapacityReached?: boolean; historyPaginationError?: boolean; onLoadOlderHistory?: (options?: { force?: boolean }) => Promise; + /** + * Identity of the session whose transcript `messages` show. Block ids are + * per-session ordinals, so changing it resets every session-scoped UI state + * (collapse overrides, pagination keep-open, pending page snapshots, the + * scroll anchor) — a direct session switch never renders empty messages. + */ + sessionKey?: string; transcriptBlockCount?: number; transcriptActivity?: { getSnapshot(): { @@ -293,7 +301,7 @@ export interface SessionTimelineRange { function isAgentOnlyToolGroup(msg: Message): boolean { return ( msg.role === 'tool_group' && - !msg.id.startsWith('summary-') && + summaryRunFirstMemberId(msg.id) === undefined && msg.tools.length === 1 && isSubAgentToolCall(msg.tools[0]) ); @@ -302,7 +310,7 @@ function isAgentOnlyToolGroup(msg: Message): boolean { function isBackgroundAgentOnlyToolGroup(msg: Message): boolean { return ( msg.role === 'tool_group' && - !msg.id.startsWith('summary-') && + summaryRunFirstMemberId(msg.id) === undefined && msg.tools.length === 1 && isBackgroundSubAgentToolCall(msg.tools[0]) ); @@ -411,7 +419,7 @@ function mergeCompactToolGroups( // Synthetic id so the aggregated group never collides with an original // message key: React then remounts instead of carrying the expanded // summary state into non-compact mode. - id: `summary-${run[0]!.id}`, + id: summaryRunId(run[0]!.id), role: 'tool_group', tools, ...(thoughts.length > 0 ? { thoughts } : {}), @@ -430,7 +438,7 @@ function updateCompactStreamingThinkingTail( const group = messages[messages.length - 1]; if ( group?.role !== 'tool_group' || - !group.id.startsWith('summary-') || + summaryRunFirstMemberId(group.id) === undefined || !group.thoughts?.length ) { return undefined; @@ -448,7 +456,9 @@ function updateCompactStreamingThinkingTail( result[result.length - 1] = { ...group, thoughts, - ...(group.id === `summary-${tail.id}` ? { timestamp: tail.timestamp } : {}), + ...(group.id === summaryRunId(tail.id) + ? { timestamp: tail.timestamp } + : {}), }; return result; } @@ -665,6 +675,14 @@ export interface ApplyTurnCollapseOptions { */ pendingApprovalCallId?: string | null; includeSubagentToolUsageInMetrics?: boolean; + /** + * Turns kept expanded despite being complete. Two sources: pagination + * split-turn detection (the tail was on screen before pagination completed + * the user prompt), and the anchor fallback (a turn collapsed while a + * history anchor sat on it). Entries persist until the user toggles the + * turn or the screen is cleared; an explicit user toggle always wins. + */ + paginatedExpanded?: ReadonlySet; /** Master switch; when false the items pass through untouched. */ enabled: boolean; } @@ -982,6 +1000,44 @@ function isTurnStartMessage(message: Message): boolean { return message.role === 'user' || message.role === 'user_shell'; } +// Find the turn head (user message id) owning the message referenced by a +// `msg:` row key, when the message is still present. +function turnIdOfMessageRow( + messages: Message[], + rowKey: string, +): string | undefined { + if (!rowKey.startsWith('msg:')) return undefined; + let messageId = rowKey.slice('msg:'.length); + // Compact-aggregate row keys embed the run's first member id. Resolve + // through it so the key still maps to its turn when looked up in the raw + // message list, including after a page extending the run re-keyed the + // aggregate. + messageId = summaryRunFirstMemberId(messageId) ?? messageId; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.id !== messageId) continue; + for (let j = i; j >= 0; j--) { + const candidate = messages[j]; + if (candidate && isTurnStartMessage(candidate)) return candidate.id; + } + return undefined; + } + return undefined; +} + +// True when the given turn currently renders as a collapsed turn_collapse row. +function isTurnCollapsedInVisibleItems( + items: DisplayItem[], + turnId: string, +): boolean { + for (const item of items) { + if (item.type === 'turn_collapse' && item.turnCollapse.turnId === turnId) { + return item.turnCollapse.collapsed; + } + } + return false; +} + function timelineDetailSnippetForMessage( message: Message, t?: (key: string, vars?: Record) => string, @@ -1798,6 +1854,7 @@ export function applyTurnCollapse( automaticallyExpandedAgentKeys, pendingApprovalCallId, includeSubagentToolUsageInMetrics = true, + paginatedExpanded, enabled, }: ApplyTurnCollapseOptions, ): DisplayItem[] { @@ -1953,7 +2010,8 @@ export function applyTurnCollapse( // turn is incomplete; otherwise it collapses once a newer turn starts. A // step-less turn (e.g. a plain "hi" reply) has nothing to fold, so it stays // expanded and shows a chevron-less metrics line. An explicit user toggle - // always wins. + // always wins; a turn whose tail was shown before pagination finished its + // head stays open so that content never vanishes mid-read. const shouldStayOpen = isActiveTurn || hasActiveAgent || @@ -1967,7 +2025,9 @@ export function applyTurnCollapse( ? true : overrides.has(turnId) ? (overrides.get(turnId) as boolean) - : shouldStayOpen; + : paginatedExpanded?.has(turnId) + ? true + : shouldStayOpen; const collapsed = !expanded; // Push the user message result.push({ @@ -2727,6 +2787,7 @@ export const MessageList = memo( forwardRef(function MessageList( { messages, + sessionKey, terminalBackgroundShellTaskIds, pendingApproval, onShowContextDetail, @@ -3262,6 +3323,20 @@ export const MessageList = memo( const [collapseOverrides, setCollapseOverrides] = useState< ReadonlyMap >(() => new Map()); + // Turns kept expanded despite being complete. Two sources: pagination + // split-turn detection (tail was on screen before the user prompt + // arrived), and the anchor fallback (a turn collapsed while a history + // anchor sat on it). Entries persist until the user toggles the turn or + // the screen is cleared. + const [paginatedExpandedTurns, setPaginatedExpandedTurns] = useState< + ReadonlySet + >(() => new Set()); + // Pending split-turn detection snapshots keyed by load generation: a load + // can resolve SDK-side before its page commits to the transcript, letting + // a next load enqueue its own snapshot before the earlier page lands. + const pendingPaginationTurnCompares = useRef( + new Map; resolved: boolean }>(), + ); const [turnLayoutPending, startTurnLayoutTransition] = useTransition(); const turnLayoutTransitionStarted = useRef(false); const turnLayoutRowTops = useRef(new Map()); @@ -3337,11 +3412,34 @@ export const MessageList = memo( useLayoutEffect(() => { mergedMessageCountRef.current = mergedMessages.length; }, [mergedMessages.length]); + const rawMessagesRef = useRef(messages); + useLayoutEffect(() => { + rawMessagesRef.current = messages; + }, [messages]); const [ suppressOlderHistoryLoadingStatus, setSuppressOlderHistoryLoadingStatus, ] = useState(false); + // A direct session switch never renders empty messages (the provider + // batches the store reset and the replay into one notification), so the + // /clear reset never runs for it. Block ids are per-session ordinals, so + // every session-scoped state must drop when the displayed session + // changes: stale keep-open and snapshot entries would collide with the + // new session's reused ids, and an old anchor would restore the previous + // session's scroll metrics into the new transcript. + const [trackedSessionKey, setTrackedSessionKey] = useState(sessionKey); + if (trackedSessionKey !== sessionKey) { + setTrackedSessionKey(sessionKey); + pendingPaginationTurnCompares.current.clear(); + olderHistoryLoadGeneration.current += 1; + olderHistoryLoadInFlight.current = false; + olderHistoryRetryBlocked.current = false; + setOlderHistoryAnchor(null); + setCollapseOverrides((prev) => (prev.size ? new Map() : prev)); + setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev)); + } + useEffect(() => { if (!hasOlderHistory) { olderHistoryRetryBlocked.current = false; @@ -3408,6 +3506,7 @@ export const MessageList = memo( turnFileChanges, turnArtifacts, turnScheduledTasks, + paginatedExpandedTurns, ] as const; const cached = visibleItemsCache.current; const currentTail = displayItems[displayItems.length - 1]; @@ -3459,6 +3558,7 @@ export const MessageList = memo( automaticallyExpandedAgentKeys, pendingApprovalCallId: pendingApproval?.toolCallId ?? null, includeSubagentToolUsageInMetrics, + paginatedExpanded: paginatedExpandedTurns, enabled: collapseEnabled, }); let metricsApplied = false; @@ -3539,6 +3639,7 @@ export const MessageList = memo( turnFileChanges, turnArtifacts, turnScheduledTasks, + paginatedExpandedTurns, ]); const virtualizerItems = visibleItemsCache.current?.sourceMessages === messages @@ -3848,6 +3949,15 @@ export const MessageList = memo( }); turnLayoutTransitionStarted.current = true; startTurnLayoutTransition(() => { + // An explicit toggle is the user's own decision: it wins over the + // pagination keep-open and returns the turn to normal collapse + // semantics. + setPaginatedExpandedTurns((prev) => { + if (!prev.has(turnId)) return prev; + const next = new Set(prev); + next.delete(turnId); + return next; + }); setCollapseOverrides((prev) => { const next = new Map(prev); next.set(turnId, nextExpanded); @@ -4069,6 +4179,40 @@ export const MessageList = memo( olderHistoryAnchor.rowKey && !hasVisibleRowKey(olderHistoryAnchor.rowKey) ) { + // The anchor row can vanish because its turn collapsed while the page + // was loading (a turn completed by pagination). Re-expand that turn so + // the anchor can be restored instead of dropping the reader's position. + const anchorTurnId = turnIdOfMessageRow( + rawMessagesRef.current, + olderHistoryAnchor.rowKey, + ); + if ( + anchorTurnId !== undefined && + isTurnCollapsedInVisibleItems(visibleItems, anchorTurnId) + ) { + // Re-expanding only helps when it can actually take effect: an + // explicit user collapse override wins over the keep-open, and a + // turn already marked keep-open yet still hidden cannot be helped. + // Fall through and drop the anchor in those cases, or pagination + // state stays pinned in-flight forever (every later load bails at + // the in-flight guard). + if ( + collapseOverrides.get(anchorTurnId) !== false && + !paginatedExpandedTurns.has(anchorTurnId) + ) { + setPaginatedExpandedTurns((prev) => + prev.has(anchorTurnId) ? prev : new Set(prev).add(anchorTurnId), + ); + // A page extending the turn's aggregated run re-keys the captured + // row, so anchor on the turn's user row instead: the re-expanded + // turn always renders it, and the restore path then adjusts the + // scroll position rather than dropping the anchor. + setOlderHistoryAnchor((anchor) => + anchor ? { ...anchor, rowKey: `msg:${anchorTurnId}` } : anchor, + ); + return; + } + } if ( olderHistoryAnchor.generation === olderHistoryLoadGeneration.current ) { @@ -4163,6 +4307,8 @@ export const MessageList = memo( mergedMessages.length, olderHistoryAnchor, visibleItems, + collapseOverrides, + paginatedExpandedTurns, ]); const virtualItems = virtualizer.getVirtualItems(); const totalVirtualSize = virtualizer.getTotalSize(); @@ -4517,7 +4663,20 @@ export const MessageList = memo( : {})), }); try { + // Remember which messages were on screen before the page arrives so + // a turn the daemon split across pages (tail shown first, head + // completing later) can be detected and kept expanded. + pendingPaginationTurnCompares.current.set(generation, { + ids: new Set(rawMessagesRef.current.map((message) => message.id)), + resolved: false, + }); await onLoadOlderHistory(force ? { force: true } : undefined); + // The snapshot belongs to this load regardless of anchor state: a + // superseding anchor drop bumps the generation mid-flight and would + // otherwise orphan the entry unresolved at the FIFO head, wedging + // every later split-turn detection. + const compare = pendingPaginationTurnCompares.current.get(generation); + if (compare) compare.resolved = true; if (generation === olderHistoryLoadGeneration.current) { setOlderHistoryAnchor((anchor) => anchor?.generation === generation @@ -4526,6 +4685,9 @@ export const MessageList = memo( ); } } catch { + // A failed load commits no page, so its snapshot is always safe to + // drop, including when the load was superseded mid-flight. + pendingPaginationTurnCompares.current.delete(generation); if (generation === olderHistoryLoadGeneration.current) { olderHistoryRetryBlocked.current = true; olderHistoryLoadInFlight.current = false; @@ -4553,6 +4715,86 @@ export const MessageList = memo( void loadOlderHistory(true, true); }, [loadOlderHistory]); + // After a pagination page commits, mark turns whose user prompt just + // arrived while their tail was already on screen (the daemon split the + // turn across pages). `applyTurnCollapse` keeps those expanded so the + // content the user is reading never silently collapses. + useLayoutEffect(() => { + const pending = pendingPaginationTurnCompares.current; + if (pending.size === 0) return; + // Skip locally-injected messages (recap ids `local-*`): /recap re-pins + // them at index 0 on every render and pagination cannot move them, so + // they never reflect a page landing and would pin the head-change check + // forever, wedging split-turn detection for the rest of the session. + const first = messages.find( + (message) => !message?.id.startsWith('local-'), + ); + if (!first) return; + const oldest = pending.entries().next().value; + if (!oldest) return; + const [generation, compare] = oldest; + // A page prepends older messages at the head; live messages that land + // while the fetch is in flight append at the tail. Wait for the head to + // change so a mid-flight live update cannot consume the snapshot before + // the page commits (which would silently skip the split-turn detection). + // Compare raw message ids, not the compact-mode aggregate ids: a page + // that extends an aggregated run re-keys the synthetic summary id, but + // the run members keep their daemon ids. + if (compare.ids.has(first.id)) return; + const newTurnIds: string[] = []; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if ( + !message || + !isTurnStartMessage(message) || + compare.ids.has(message.id) + ) { + continue; + } + let tailShown = false; + for (let j = i + 1; j < messages.length; j++) { + const next = messages[j]; + if (isTurnStartMessage(next)) break; + if (compare.ids.has(next.id)) { + tailShown = true; + break; + } + } + if (tailShown) newTurnIds.push(message.id); + } + if (newTurnIds.length === 0) { + // A changed head while the load is resolved is the page landing, so + // consume the snapshot even when it completed no split turn — + // otherwise every later transcript update rescans it for the rest of + // the session. While the load is still in flight a head change is + // not the page (a transcript reload or session/branch switch); + // discarding the snapshot there would let the page commit that + // follows silently skip detection — the split turn collapses again, + // the bug this code exists to fix. + if (!compare.resolved) return; + } else { + setPaginatedExpandedTurns((prev) => { + let next: Set | null = null; + for (const id of newTurnIds) { + if (!prev.has(id)) { + next ??= new Set(prev); + next.add(id); + } + } + return next ?? prev; + }); + } + pending.delete(generation); + // The page that just committed is visible now: fold its ids into any + // newer pending snapshot so that load's detection still recognizes the + // tail a mid-turn page boundary showed. + for (const remaining of pending.values()) { + for (const message of messages) { + if (message) remaining.ids.add(message.id); + } + } + }, [messages]); + useEffect(() => { const pendingGeneration = pendingOlderHistoryTopLoad.current; if ( @@ -4821,7 +5063,12 @@ export const MessageList = memo( pendingBottomFollowAfterCooldown.current = false; setShouldFollow(true); pendingScrollRef.current = null; + // Drop the in-flight pagination snapshots too: without this a stale + // pre-clear snapshot survives into the next session and can mislabel + // a complete turn as keep-open (block ids are per-session ordinals). + pendingPaginationTurnCompares.current.clear(); setCollapseOverrides((prev) => (prev.size ? new Map() : prev)); + setPaginatedExpandedTurns((prev) => (prev.size ? new Set() : prev)); } }, [messages.length, setShouldFollow]); diff --git a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css index f8c5c528237..2890c2054e2 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -427,9 +427,11 @@ .lineName { flex-shrink: 0; + font-size: 13px; } .lineArg { + font-size: 13px; min-width: 0; flex: 1; overflow: hidden; diff --git a/packages/web-shell/client/components/summaryRunId.ts b/packages/web-shell/client/components/summaryRunId.ts new file mode 100644 index 00000000000..5134b4e5cd6 --- /dev/null +++ b/packages/web-shell/client/components/summaryRunId.ts @@ -0,0 +1,15 @@ +/** + * Synthetic ids for compact-mode aggregated tool runs. + * `mergeCompactToolGroups` builds them via `summaryRunId`; render paths detect + * them through the shared predicate so the prefix scheme lives in one place. + */ +export const SUMMARY_RUN_ID_PREFIX = 'summary-'; + +export const summaryRunId = (firstMemberId: string): string => + `${SUMMARY_RUN_ID_PREFIX}${firstMemberId}`; + +export const isSummaryRunId = (id: string): boolean => + id.startsWith(SUMMARY_RUN_ID_PREFIX); + +export const summaryRunFirstMemberId = (id: string): string | undefined => + isSummaryRunId(id) ? id.slice(SUMMARY_RUN_ID_PREFIX.length) : undefined;