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