fix(desktop): keep permission actions visible #932

This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 17:41:34 +08:00
parent 78ef57d63d
commit ace8862ff2
2 changed files with 291 additions and 3 deletions

View File

@ -3093,6 +3093,241 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
})
it('keeps a pending file permission pinned when its preview grows after render', async () => {
const observers: Array<{
callback: ResizeObserverCallback
targets: Element[]
}> = []
class TestResizeObserver {
targets: Element[] = []
observe = vi.fn((target: Element) => {
this.targets.push(target)
})
unobserve = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) {
observers.push({ callback, targets: this.targets })
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
const pendingPermission = {
requestId: 'permission-write-memory',
toolName: 'Write',
input: {
file_path: '/tmp/MEMORY.md',
content: Array.from({ length: 80 }, (_, index) => `Memory line ${index}`).join('\n'),
},
}
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'permission_pending',
pendingPermission,
pendingPermissions: {
[pendingPermission.requestId]: pendingPermission,
},
messages: [
...Array.from({ length: 130 }, (_, index) => ({
id: `assistant-history-${index}`,
type: 'assistant_text' as const,
content: `History line ${index}`,
timestamp: index,
})),
{
id: 'permission-write-memory-message',
type: 'permission_request',
requestId: pendingPermission.requestId,
toolName: pendingPermission.toolName,
input: pendingPermission.input,
timestamp: 131,
},
],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.chat-scroll-area') as HTMLDivElement
const scrollContent = scroller.firstElementChild as HTMLElement
let scrollTop = 15000
let scrollHeight = 15521
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
})
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 521 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
await waitForProgrammaticScrollReset()
fireEvent.scroll(scroller)
const allowButton = await screen.findByRole('button', { name: 'Allow: /tmp/MEMORY.md' })
const contentObserver = observers.find(({ targets }) => targets.includes(scrollContent))
expect(contentObserver).toBeTruthy()
const permissionItem = allowButton.closest('[data-virtual-message-item]') as HTMLElement
expect(permissionItem).toBeTruthy()
const permissionItemObserver = observers.find(({ targets }) => targets.includes(permissionItem))
expect(permissionItemObserver).toBeTruthy()
scrollHeight = 15717
fireEvent.scroll(scroller)
act(() => {
permissionItemObserver?.callback([{
contentRect: { height: 520 },
target: permissionItem,
} as unknown as ResizeObserverEntry], {} as ResizeObserver)
})
act(() => {
contentObserver?.callback([{
contentRect: { height: 15717 },
target: scrollContent,
} as unknown as ResizeObserverEntry], {} as ResizeObserver)
})
expect(scrollTop).toBe(15196)
expect(screen.queryByRole('button', { name: 'Latest' })).toBeNull()
})
it('does not follow resize for stale permission state without a pending file permission', async () => {
let resizeCallback: ResizeObserverCallback | null = null
class TestResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'permission_pending',
messages: [{
id: 'assistant-restored',
type: 'assistant_text',
content: 'Restored completed response',
timestamp: 1,
}],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.chat-scroll-area') as HTMLDivElement
let scrollTop = 600
let scrollHeight = 1000
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
})
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
await waitForProgrammaticScrollReset()
fireEvent.scroll(scroller)
scrollHeight = 1400
act(() => {
resizeCallback?.([{
contentRect: { height: 1400 },
} as ResizeObserverEntry], {} as ResizeObserver)
})
expect(scrollTop).toBe(600)
})
it('preserves upward wheel and keyboard intent while a pending file permission preview resizes', async () => {
let resizeCallback: ResizeObserverCallback | null = null
class TestResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
const pendingPermission = {
requestId: 'permission-write-memory-wheel',
toolName: 'Write',
input: {
file_path: '/tmp/MEMORY.md',
content: 'Remember this preference',
},
}
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'permission_pending',
pendingPermission,
pendingPermissions: {
[pendingPermission.requestId]: pendingPermission,
},
messages: [{
id: 'permission-write-memory-wheel-message',
type: 'permission_request',
requestId: pendingPermission.requestId,
toolName: pendingPermission.toolName,
input: pendingPermission.input,
timestamp: 1,
}],
}),
},
})
const { container } = render(<MessageList />)
const scroller = container.querySelector('.chat-scroll-area') as HTMLDivElement
let scrollTop = 600
let scrollHeight = 1000
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
})
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = value
},
})
await waitForProgrammaticScrollReset()
fireEvent.scroll(scroller)
fireEvent.wheel(scroller, { deltaY: -120 })
fireEvent.keyDown(scroller, { key: 'PageUp', shiftKey: false })
fireEvent.keyDown(scroller, { key: 'ArrowDown', shiftKey: false })
scrollTop = 300
scrollHeight = 1400
act(() => {
resizeCallback?.([{
contentRect: { height: 1400 },
} as ResizeObserverEntry], {} as ResizeObserver)
})
expect(scrollTop).toBe(300)
})
it('lets the user drag away from active thinking output before the programmatic scroll settles', async () => {
let resizeCallback: ResizeObserverCallback | null = null
class TestResizeObserver {

View File

@ -949,6 +949,7 @@ const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
// Windows WebView2 can report 1px oscillations for live chat content; don't
// convert those into bottom-scroll corrections.
const CONTENT_RESIZE_FOLLOW_MIN_DELTA_PX = 2
const USER_SCROLL_INTENT_WINDOW_MS = 500
const CONVERSATION_NAVIGATION_MIN_ITEMS = 4
const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message'
const EMPTY_MESSAGES: UIMessage[] = []
@ -1432,14 +1433,19 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const pendingPermissions = listPendingPermissions(sessionState)
const activeAskUserQuestionToolUseId =
listPendingPermissions(sessionState)
pendingPermissions
.find((permission) => permission.toolName === 'AskUserQuestion')?.toolUseId ?? null
const hasPendingPermissionCard = pendingPermissions.some(
(permission) => permission.toolName !== 'AskUserQuestion',
)
const shouldFollowContentResize =
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
chatState === 'compacting' ||
chatState === 'tool_executing' ||
hasPendingPermissionCard ||
(chatState === 'thinking' && Boolean(activeThinkingId))
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
@ -1458,6 +1464,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
const ignoreProgrammaticScrollTopRef = useRef<number | null>(null)
const userScrollIntentUntilRef = useRef(0)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
const t = useTranslation()
@ -1570,6 +1577,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
if (previousHeight !== undefined && Math.abs(previousHeight - measuredHeight) < 1) return
virtualItemHeightsRef.current.set(itemKey, measuredHeight)
if (hasPendingPermissionCard && shouldAutoScrollRef.current) {
scrollToBottom('auto')
}
if (typeof requestAnimationFrame === 'undefined') {
pendingMeasuredHeightsRef.current = true
@ -1584,7 +1594,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
flushMeasuredHeightVersion()
})
}
}, [flushMeasuredHeightVersion])
}, [flushMeasuredHeightVersion, hasPendingPermissionCard, scrollToBottom])
const updateAutoScrollState = useCallback(() => {
// Ignore scroll events triggered by our own programmatic scrolling to
@ -1606,13 +1616,52 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
}
syncVirtualViewportFromContainer(container)
const isAtBottom = isNearScrollBottom(container)
const isPermissionLayoutShift =
hasPendingPermissionCard &&
shouldAutoScrollRef.current &&
!isAtBottom &&
performance.now() >= userScrollIntentUntilRef.current
if (isPermissionLayoutShift) return
shouldAutoScrollRef.current = isAtBottom
setShowJumpToLatest(!isAtBottom)
if (resolvedSessionId) {
rememberSessionScroll(resolvedSessionId, container)
}
}, [resolvedSessionId, syncVirtualViewportFromContainer])
}, [hasPendingPermissionCard, resolvedSessionId, syncVirtualViewportFromContainer])
const markUserScrollIntent = useCallback(() => {
userScrollIntentUntilRef.current = performance.now() + USER_SCROLL_INTENT_WINDOW_MS
}, [])
const handleWheelScrollIntent = useCallback((event: { deltaY: number }) => {
markUserScrollIntent()
if (event.deltaY < 0) {
shouldAutoScrollRef.current = false
setShowJumpToLatest(true)
}
}, [markUserScrollIntent])
const handleKeyDownScrollIntent = useCallback((event: { key: string; shiftKey: boolean }) => {
const isUpwardScrollKey =
event.key === 'ArrowUp' ||
event.key === 'PageUp' ||
event.key === 'Home' ||
(event.key === ' ' && event.shiftKey)
const isScrollKey = isUpwardScrollKey ||
event.key === 'ArrowDown' ||
event.key === 'PageDown' ||
event.key === 'End' ||
event.key === ' '
if (!isScrollKey) return
markUserScrollIntent()
if (isUpwardScrollKey) {
shouldAutoScrollRef.current = false
setShowJumpToLatest(true)
}
}, [markUserScrollIntent])
useLayoutEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
@ -2187,6 +2236,10 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
onWheel={handleWheelScrollIntent}
onPointerDown={markUserScrollIntent}
onTouchStart={markUserScrollIntent}
onKeyDown={handleKeyDownScrollIntent}
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div