fix: keep chat tabs from losing scroll context

Chat sessions need predictable navigation when users switch tabs or read history during streaming. This preserves each session's scroll position, defaults fresh sessions to the latest message, and adds a compact jump-to-latest affordance when auto-follow is paused.

The PR gate also exposed that provider-scoped scheduled tasks must force the sdk-cli entrypoint when launched through the sidecar, so the same change records that runtime contract and its regression assertion.

Constraint: Desktop chat should not force-scroll while the user is reading older messages
Constraint: Scheduled task provider env must not inherit stale parent model runtime values
Rejected: Persist scroll positions in localStorage | session scroll is transient UI state and should not survive app restarts
Confidence: high
Scope-risk: moderate
Directive: Do not remove the sdk-cli entrypoint marker from provider-scoped cron tasks without rerunning the sidecar launcher regression
Tested: cd desktop && bun run test -- MessageList.test.tsx
Tested: cd desktop && bun run lint
Tested: bun test src/server/__tests__/cron-scheduler-launcher.test.ts src/utils/__tests__/themeWords.test.ts
Tested: bun run check:desktop
Tested: bun run check:server
Tested: bun run check:native
Not-tested: bun run verify still fails on pre-existing agent-utils global coverage baseline below threshold
Not-tested: Chrome extension E2E blocked by Codex Chrome Extension communication timeout after plugin diagnostics passed
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 21:51:34 +08:00
parent 3193e741c7
commit 6382115de9
7 changed files with 409 additions and 82 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,84 +525,99 @@ 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
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
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

@ -823,6 +823,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

@ -825,6 +825,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

@ -295,6 +295,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

@ -666,7 +666,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)
})
})