diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx
index a4020499..e5cb3874 100644
--- a/desktop/src/components/chat/MessageList.test.tsx
+++ b/desktop/src/components/chat/MessageList.test.tsx
@@ -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()
+ 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()
+ 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()
+ 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 {
diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx
index d49b7900..685d8ace 100644
--- a/desktop/src/components/chat/MessageList.tsx
+++ b/desktop/src/components/chat/MessageList.tsx
@@ -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(null)
const scrollContentRef = useRef(null)
@@ -1458,6 +1464,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const isProgrammaticScrollingRef = useRef(false)
const ignoreProgrammaticScrollUntilRef = useRef(0)
const ignoreProgrammaticScrollTopRef = useRef(null)
+ const userScrollIntentUntilRef = useRef(0)
const lastSessionIdRef = useRef(resolvedSessionId)
const lastTailMessageIdBySessionRef = useRef(new Map())
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 = {