mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge desktop long-session performance fix into local main
Bring the validated detached-worktree desktop performance patch into the canonical local main checkout so long-session tab and scroll fixes are available from the main working copy. Constraint: Local main had advanced independently beyond the detached worktree base, so fast-forward was not the correct landing shape. Confidence: high Scope-risk: moderate Directive: Verify ancestry for detached-worktree landings because main may advance after the feature worktree is created. Tested: bun run check:desktop in detached worktree before merge. Not-tested: Post-merge desktop gate before this merge commit was created.
This commit is contained in:
commit
385435a04d
@ -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(<MessageList />)
|
||||
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(<MessageList />)
|
||||
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', {
|
||||
|
||||
@ -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 (
|
||||
<div key={itemKey}>
|
||||
<div key={itemKey} className={CHAT_RENDER_ITEM_CLASS}>
|
||||
{item.kind === 'tool_group' ? (
|
||||
<ToolCallGroup
|
||||
toolCalls={item.toolCalls}
|
||||
|
||||
@ -357,6 +357,59 @@ describe('TabBar', () => {
|
||||
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<ReturnType<typeof useChatStore.getState>>)
|
||||
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(<TabBar />)
|
||||
})
|
||||
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')
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user