diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index bdd9cf4e..874bab71 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -153,6 +153,39 @@ describe('MessageList nested tool calls', () => { expect(container.querySelector('[data-virtual-message-item]')).toBeNull() }) + it('marks transcript items as offscreen-renderable without virtualizing variable-height rows', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'assistant-1', + type: 'assistant_text', + content: 'first assistant reply', + timestamp: 1, + }, + { + id: 'assistant-2', + type: 'assistant_text', + content: 'second assistant reply', + timestamp: 2, + }, + ], + }), + }, + }) + + const { container } = render() + const renderItems = container.querySelectorAll('.chat-render-item') + + expect(renderItems).toHaveLength(2) + for (const item of renderItems) { + expect(item.className).toContain('[content-visibility:auto]') + expect(item.className).toContain('[contain-intrinsic-size:auto_180px]') + } + expect(container.querySelector('[data-virtual-message-item]')).toBeNull() + }) + it('filters duplicate unresolved AskUserQuestion cards while a matching permission is pending', () => { const messages: UIMessage[] = [ { @@ -1734,6 +1767,78 @@ describe('MessageList nested tool calls', () => { expect(scrollTop).toBe(600) }) + it('keeps auto-scrolling without reading scroll geometry synchronously', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'streaming', + messages: [ + { + id: 'user-1', + type: 'user_text', + content: 'latest prompt', + timestamp: 1, + }, + ], + streamingText: 'streaming', + }), + }, + }) + + const { container } = render() + const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement + const readScrollHeight = vi.fn(() => { + throw new Error('scrollHeight should not be read while pinning to bottom') + }) + const readClientHeight = vi.fn(() => { + throw new Error('clientHeight should not be read while pinning to bottom') + }) + let scrollTop = 552 + Object.defineProperty(scroller, 'scrollHeight', { + configurable: true, + get: readScrollHeight, + }) + Object.defineProperty(scroller, 'clientHeight', { + configurable: true, + get: readClientHeight, + }) + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value) => { + scrollTop = value >= 1_000_000_000 ? 600 : value + }, + }) + Object.defineProperty(scroller, 'scrollTo', { + configurable: true, + value: vi.fn((options: ScrollToOptions | number, y?: number) => { + scroller.scrollTop = typeof options === 'number' ? y ?? 0 : options.top ?? 0 + }), + }) + + await waitForProgrammaticScrollReset() + act(() => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + [ACTIVE_TAB]: { + ...state.sessions[ACTIVE_TAB]!, + streamingText: 'streaming next token', + }, + }, + })) + }) + + await waitFor(() => { + expect(screen.getByText('streaming next token')).toBeTruthy() + }) + await waitForProgrammaticScrollReset() + + expect(scrollTop).toBe(600) + expect(readScrollHeight).not.toHaveBeenCalled() + expect(readClientHeight).not.toHaveBeenCalled() + }) + it('keeps mobile H5 streaming output pinned after the transcript height grows', async () => { const scrollIntoView = vi.fn() Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a47b2474..237c3cc1 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -782,6 +782,7 @@ type MessageListProps = { } const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 +const SCROLL_BOTTOM_SENTINEL = 1_000_000_000 const MAX_SCROLL_SNAPSHOTS = 100 const EMPTY_MESSAGES: UIMessage[] = [] const CHAT_SCROLL_AREA_CLASS = [ @@ -798,6 +799,11 @@ const CHAT_SCROLL_AREA_CLASS = [ '[&::-webkit-scrollbar-thumb:hover]:border-2', '[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]', ].join(' ') +const CHAT_RENDER_ITEM_CLASS = [ + 'chat-render-item', + '[content-visibility:auto]', + '[contain-intrinsic-size:auto_180px]', +].join(' ') type SessionScrollSnapshot = { scrollTop: number @@ -827,14 +833,32 @@ function rememberSessionScroll(sessionId: string, element: HTMLElement) { }) } -function clampScrollTop(element: HTMLElement, scrollTop: number) { - return Math.max(0, Math.min(scrollTop, getBottomScrollTop(element))) -} - function getBottomScrollTop(element: HTMLElement) { return Math.max(0, element.scrollHeight - element.clientHeight) } +function setScrollTopWithoutLayoutRead(element: HTMLElement, scrollTop: number) { + element.scrollTop = Math.max(0, scrollTop) +} + +function setScrollToBottomWithoutLayoutRead(element: HTMLElement, behavior: ScrollBehavior) { + if (typeof element.scrollTo === 'function') { + try { + element.scrollTo({ top: SCROLL_BOTTOM_SENTINEL, behavior }) + } catch { + element.scrollTo(0, SCROLL_BOTTOM_SENTINEL) + } + } + element.scrollTop = SCROLL_BOTTOM_SENTINEL + + // Browsers clamp the large value to the true bottom without needing us to + // synchronously read layout metrics. JSDOM test doubles do not clamp, so keep + // the old numeric behavior there as a fallback. + if (element.scrollTop === SCROLL_BOTTOM_SENTINEL) { + element.scrollTop = getBottomScrollTop(element) + } +} + export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId @@ -893,21 +917,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { shouldAutoScrollRef.current = true isProgrammaticScrollingRef.current = true const container = scrollContainerRef.current - const targetScrollTop = container ? getBottomScrollTop(container) : null + let requestedScrollTop: number | null = null if (container) { - const nextScrollTop = targetScrollTop ?? 0 - if (typeof container.scrollTo === 'function') { - try { - container.scrollTo({ top: nextScrollTop, behavior }) - } catch { - container.scrollTo(0, nextScrollTop) - } - } - container.scrollTop = nextScrollTop + setScrollToBottomWithoutLayoutRead(container, behavior) + requestedScrollTop = container.scrollTop } if (container && resolvedSessionId) { sessionScrollSnapshots.set(resolvedSessionId, { - scrollTop: getBottomScrollTop(container), + scrollTop: container.scrollTop, wasAtBottom: true, }) } @@ -919,15 +936,14 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { shouldAutoScrollRef.current && latestContainer && ( - targetScrollTop === null || - latestContainer.scrollTop === targetScrollTop || - isNearScrollBottom(latestContainer) + requestedScrollTop === null || + latestContainer.scrollTop === requestedScrollTop ) ) { - latestContainer.scrollTop = getBottomScrollTop(latestContainer) + setScrollToBottomWithoutLayoutRead(latestContainer, 'auto') if (resolvedSessionId) { sessionScrollSnapshots.set(resolvedSessionId, { - scrollTop: getBottomScrollTop(latestContainer), + scrollTop: latestContainer.scrollTop, wasAtBottom: true, }) } @@ -959,7 +975,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const container = scrollContainerRef.current if (container && snapshot && !snapshot.wasAtBottom) { - container.scrollTop = clampScrollTop(container, snapshot.scrollTop) + setScrollTopWithoutLayoutRead(container, snapshot.scrollTop) setShowJumpToLatest(true) } else { scrollToBottom('auto') @@ -1197,7 +1213,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const cardsForItem = turnCardsByRenderIndex.get(index) ?? [] return ( -
+
{item.kind === 'tool_group' ? ( { expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree') }) + it('does not rerender for chat payload changes when tab running state is unchanged', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + const { useSessionStore } = await import('../../stores/sessionStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'Workspace Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: { + 'tab-1': makeChatSession('idle'), + }, + disconnectSession: vi.fn(), + } as Partial>) + useSessionStore.setState({ + sessions: [{ + id: 'tab-1', + title: 'Workspace Session', + createdAt: '2026-05-13T00:00:00.000Z', + modifiedAt: '2026-05-13T00:00:00.000Z', + messageCount: 0, + projectPath: '/repo', + workDir: '/repo/worktree', + workDirExists: true, + }], + activeSessionId: 'tab-1', + }) + + await act(async () => { + render() + }) + expect(openProjectMenuMock.paths[openProjectMenuMock.paths.length - 1]).toBe('/repo/worktree') + + openProjectMenuMock.paths = [] + await act(async () => { + useChatStore.setState((state) => ({ + sessions: { + ...state.sessions, + 'tab-1': { + ...state.sessions['tab-1']!, + streamingText: 'token churn should not affect tab chrome', + }, + }, + })) + }) + + expect(openProjectMenuMock.paths).toEqual([]) + }) + it('hides the open-project control when the active session workdir is unavailable', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 25495157..eec97b5c 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,4 +1,5 @@ import { forwardRef, useMemo, useRef, useState, useEffect, useCallback } from 'react' +import { useShallow } from 'zustand/react/shallow' import { SCHEDULED_TAB_ID, SETTINGS_TAB_ID, @@ -44,7 +45,13 @@ export function TabBar() { const activeTabId = useTabStore((s) => s.activeTabId) const setActiveTab = useTabStore((s) => s.setActiveTab) const closeTab = useTabStore((s) => s.closeTab) - const chatSessions = useChatStore((s) => s.sessions) + const sessionTabIds = useMemo( + () => tabs.filter((tab) => isSessionTab(tab)).map((tab) => tab.sessionId), + [tabs], + ) + const activeChatSessionIds = useChatStore(useShallow((s) => + sessionTabIds.filter((sessionId) => s.sessions[sessionId]?.chatState !== 'idle') + )) const disconnectSession = useChatStore((s) => s.disconnectSession) const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null const isActiveSessionTab = isSessionTab(activeTab) || isSessionTabId(activeTabId) @@ -81,11 +88,11 @@ export function TabBar() { for (const tab of tabs) { if (isSessionTab(tab) && tab.status === 'running') ids.add(tab.sessionId) } - for (const [sessionId, sessionState] of Object.entries(chatSessions)) { - if (sessionState.chatState !== 'idle') ids.add(sessionId) + for (const sessionId of activeChatSessionIds) { + ids.add(sessionId) } return ids - }, [chatSessions, tabs]) + }, [activeChatSessionIds, tabs]) useEffect(() => { if (!isTauri) return