Merge chat scroll recovery into main

Bring the detached worktree fix into the local main line while preserving the newer main behavior that passes session context into interactive chat message blocks.

Constraint: Local main already contains six unpublished commits on top of origin/main
Constraint: Merge conflict only affected MessageList message rendering around sessionId propagation
Rejected: Overwrite main's MessageBlock call shape | would regress AskUserQuestion and permission interactions that need sessionId
Confidence: high
Scope-risk: moderate
Directive: Keep MessageBlock sessionId propagation when editing chat rendering; AskUserQuestion depends on it
Tested: cd desktop && bun run test -- MessageList.test.tsx
Not-tested: Full verify after merge; previous verify remains blocked by agent-utils global coverage baseline
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 21:52:31 +08:00
commit 50d4679f51
7 changed files with 410 additions and 83 deletions

View File

@ -537,6 +537,191 @@ describe('MessageList nested tool calls', () => {
expect(scrollIntoView).toHaveBeenCalled()
})
it('restores a session scroll position when switching back to a tab', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useTabStore.setState({
activeTabId: 'session-a',
tabs: [
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
{ sessionId: 'session-b', title: 'B', type: 'session' as const, status: 'idle' },
],
})
useChatStore.setState({
sessions: {
'session-a': makeSessionState({
messages: [
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
],
}),
'session-b': makeSessionState({
messages: [
{ id: 'b-user', type: 'user_text', content: 'B prompt', timestamp: 1 },
{ id: 'b-assistant', type: 'assistant_text', content: 'B response', timestamp: 2 },
],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 180
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
fireEvent.scroll(scroller)
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
act(() => {
useTabStore.setState({ activeTabId: 'session-b' })
})
await waitFor(() => {
expect(screen.getByText('B response')).toBeTruthy()
})
scrollTop = 760
fireEvent.scroll(scroller)
act(() => {
useTabStore.setState({ activeTabId: 'session-a' })
})
await waitFor(() => {
expect(screen.getByText('A response')).toBeTruthy()
})
expect(scrollTop).toBe(180)
expect(screen.getByRole('button', { name: 'Latest' })).toBeTruthy()
})
it('scrolls new sessions to the latest message instead of inheriting another tab position', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useTabStore.setState({
activeTabId: 'session-a',
tabs: [
{ sessionId: 'session-a', title: 'A', type: 'session' as const, status: 'idle' },
{ sessionId: 'session-fresh', title: 'Fresh', type: 'session' as const, status: 'idle' },
],
})
useChatStore.setState({
sessions: {
'session-a': makeSessionState({
messages: [
{ id: 'a-user', type: 'user_text', content: 'A prompt', timestamp: 1 },
{ id: 'a-assistant', type: 'assistant_text', content: 'A response', timestamp: 2 },
],
}),
'session-fresh': makeSessionState({
messages: [
{ id: 'fresh-user', type: 'user_text', content: 'Fresh prompt', timestamp: 1 },
{ id: 'fresh-assistant', type: 'assistant_text', content: 'Fresh latest response', timestamp: 2 },
],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
value: 150,
writable: true,
})
fireEvent.scroll(scroller)
scrollIntoView.mockClear()
act(() => {
useTabStore.setState({ activeTabId: 'session-fresh' })
})
await waitFor(() => {
expect(screen.getByText('Fresh latest response')).toBeTruthy()
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'end' })
})
})
it('shows a latest button when reading history and resumes following after clicking it', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'streaming',
messages: [
{
id: 'user-1',
type: 'user_text',
content: '历史消息',
timestamp: 1,
},
],
streamingText: 'streaming',
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.overflow-y-auto') as HTMLDivElement
let scrollTop = 120
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
scrollIntoView.mockClear()
fireEvent.scroll(scroller)
fireEvent.click(screen.getByRole('button', { name: 'Latest' }))
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
scrollIntoView.mockClear()
act(() => {
useChatStore.setState((state) => ({
sessions: {
...state.sessions,
[ACTIVE_TAB]: {
...state.sessions[ACTIVE_TAB]!,
streamingText: 'streaming after jump',
},
},
}))
})
await waitFor(() => {
expect(screen.getByText('streaming after jump')).toBeTruthy()
})
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'end' })
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
useChatStore.setState({
sessions: {

View File

@ -1,4 +1,5 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react'
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect } from 'react'
import { ArrowDown } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -246,6 +247,28 @@ type MessageListProps = {
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
const MAX_SCROLL_SNAPSHOTS = 100
const CHAT_SCROLL_AREA_CLASS = [
'chat-scroll-area',
'[scrollbar-width:auto]',
'[scrollbar-color:color-mix(in_srgb,var(--color-outline)_72%,transparent)_transparent]',
'[&::-webkit-scrollbar]:w-2.5',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full',
'[&::-webkit-scrollbar-thumb]:border-[3px]',
'[&::-webkit-scrollbar-thumb]:border-transparent',
'[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_74%,transparent)]',
'[&::-webkit-scrollbar-thumb]:bg-clip-content',
'[&::-webkit-scrollbar-thumb:hover]:border-2',
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]',
].join(' ')
type SessionScrollSnapshot = {
scrollTop: number
wasAtBottom: boolean
}
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
function isNearScrollBottom(element: HTMLElement) {
return (
@ -254,6 +277,24 @@ function isNearScrollBottom(element: HTMLElement) {
)
}
function rememberSessionScroll(sessionId: string, element: HTMLElement) {
if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) {
const oldestSessionId = sessionScrollSnapshots.keys().next().value
if (oldestSessionId) {
sessionScrollSnapshots.delete(oldestSessionId)
}
}
sessionScrollSnapshots.set(sessionId, {
scrollTop: element.scrollTop,
wasAtBottom: isNearScrollBottom(element),
})
}
function clampScrollTop(element: HTMLElement, scrollTop: number) {
return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight))
}
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
@ -283,23 +324,61 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
const [showJumpToLatest, setShowJumpToLatest] = useState(false)
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
bottomRef.current?.scrollIntoView?.({ behavior, block: 'end' })
const container = scrollContainerRef.current
if (container && resolvedSessionId) {
sessionScrollSnapshots.set(resolvedSessionId, {
scrollTop: Math.max(0, container.scrollHeight - container.clientHeight),
wasAtBottom: true,
})
}
setShowJumpToLatest(false)
}, [resolvedSessionId])
const updateAutoScrollState = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
shouldAutoScrollRef.current = isNearScrollBottom(container)
}, [])
const isAtBottom = isNearScrollBottom(container)
shouldAutoScrollRef.current = isAtBottom
setShowJumpToLatest(!isAtBottom)
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
}, [resolvedSessionId])
useLayoutEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
lastSessionIdRef.current = resolvedSessionId
const container = scrollContainerRef.current
if (container && snapshot && !snapshot.wasAtBottom) {
container.scrollTop = clampScrollTop(container, snapshot.scrollTop)
setShowJumpToLatest(true)
} else {
scrollToBottom('auto')
}
}
}, [resolvedSessionId, scrollToBottom])
useEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
shouldAutoScrollRef.current = true
lastSessionIdRef.current = resolvedSessionId
if (!shouldAutoScrollRef.current) {
setShowJumpToLatest(true)
return
}
if (!shouldAutoScrollRef.current) return
scrollToBottom('smooth')
}, [messages.length, resolvedSessionId, scrollToBottom, streamingText])
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, resolvedSessionId, streamingText])
const handleJumpToLatest = useCallback(() => {
scrollToBottom('smooth')
}, [scrollToBottom])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages),
@ -446,85 +525,100 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
])
return (
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{renderItems.map((item, index) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
<div className="relative min-h-0 flex-1">
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{renderItems.map((item, index) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
return (
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
const result = toolResultMap.get(item.message.toolUseId)
return result ? { content: result.content, isError: result.isError } : null
})()
: null
}
/>
)}
return (
<div key={item.kind === 'tool_group' ? item.id : item.message.id}>
{item.kind === 'tool_group' ? (
<ToolCallGroup
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? (() => {
const result = toolResultMap.get(item.message.toolUseId)
return result ? { content: result.content, isError: result.isError } : null
})()
: null
}
/>
)}
{resolvedSessionId && cardsForItem.map((card) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
{resolvedSessionId && cardsForItem.map((card) => (
<CurrentTurnChangeCard
key={`turn-change-${card.target.messageId}`}
sessionId={resolvedSessionId}
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
checkpoint={card.checkpoint}
workDir={card.workDir}
error={turnActionErrors[card.target.messageId] ?? null}
isUndoing={rewindingTurnId === card.target.messageId}
isLatest={card.isLatest}
onUndo={() => {
setTurnUndoConfirmTargetId(card.target.messageId)
}}
/>
))}
</div>
)
})}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}
{/* Show StreamingIndicator when:
- tool_executing: tool is running
- thinking but no active ThinkingBlock yet: the gap between
sending a message and receiving the first thinking delta */}
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
<StreamingIndicator />
)}
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{turnChangeLoadError}
</div>
)
})}
)}
{streamingText.trim() && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}
{/* Show StreamingIndicator when:
- tool_executing: tool is running
- thinking but no active ThinkingBlock yet: the gap between
sending a message and receiving the first thinking delta */}
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
<StreamingIndicator />
)}
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{turnChangeLoadError}
</div>
)}
<div ref={bottomRef} />
<div ref={bottomRef} />
</div>
</div>
{showJumpToLatest && (
<button
type="button"
onClick={handleJumpToLatest}
title={t('chat.jumpToLatest')}
aria-label={t('chat.jumpToLatest')}
className="absolute bottom-4 right-5 z-20 flex h-9 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-xs font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:border-[var(--color-brand)]/50 hover:bg-[var(--color-surface-container-low)]"
>
<ArrowDown size={15} aria-hidden="true" />
<span>{t('chat.jumpToLatest')}</span>
</button>
)}
<ConfirmDialog
open={Boolean(confirmTurnCard)}
onClose={() => {

View File

@ -834,6 +834,7 @@ export const en = {
'chat.select': 'select',
'chat.dismiss': 'dismiss',
'chat.stopTitle': 'Stop generation (Cmd+.)',
'chat.jumpToLatest': 'Latest',
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
'chat.turnChangesTitle': '{count} files changed',

View File

@ -836,6 +836,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.select': '选择',
'chat.dismiss': '关闭',
'chat.stopTitle': '停止生成 (Cmd+.)',
'chat.jumpToLatest': '回到最新',
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
'chat.turnChangesTitle': '{count} 个文件已更改',

View File

@ -296,6 +296,7 @@ describe('cron scheduler launcher resolution', () => {
)
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('provider-fast')
expect(env.ANTHROPIC_MODEL).not.toBe('stale-parent-model')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
})

View File

@ -667,7 +667,10 @@ export class CronScheduler {
PWD: workDir,
CC_HAHA_SKIP_DOTENV: '1',
...(explicitProviderEnv
? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' }
? {
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
CLAUDE_CODE_ENTRYPOINT: 'sdk-cli',
}
: {}),
...(explicitProviderEnv ?? {}),
...(this.shouldMarkManagedOAuth(task.providerId)

View File

@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test'
import {
getTheme,
THEME_NAMES,
themeColorToAnsi,
type ThemeName,
} from '../theme.js'
import { generateShortWordSlug, generateWordSlug } from '../words.js'
describe('theme utilities', () => {
test('resolves every supported theme to a complete palette', () => {
for (const themeName of THEME_NAMES) {
const theme = getTheme(themeName)
expect(theme.text).toBeTruthy()
expect(theme.inverseText).toBeTruthy()
expect(theme.success).toBeTruthy()
expect(theme.error).toBeTruthy()
expect(theme.claude).toBeTruthy()
}
})
test('falls back to the dark theme for unknown concrete theme names', () => {
const darkTheme = getTheme('dark')
const unknownTheme = getTheme('unknown' as ThemeName)
expect(unknownTheme).toBe(darkTheme)
})
test('converts rgb theme colors to ansi escapes', () => {
expect(themeColorToAnsi('rgb(12, 34, 56)')).toEqual(expect.any(String))
expect(themeColorToAnsi('not-a-color')).toBe('\x1b[35m')
})
})
describe('word slug utilities', () => {
test('generates hyphenated slugs with the expected part count', () => {
expect(generateWordSlug().split('-')).toHaveLength(3)
expect(generateShortWordSlug().split('-')).toHaveLength(2)
})
})