From ace8862ff2da98f946e6c3191a789b7ef6f03d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Jul 2026 17:41:34 +0800 Subject: [PATCH] fix(desktop): keep permission actions visible #932 --- .../src/components/chat/MessageList.test.tsx | 235 ++++++++++++++++++ desktop/src/components/chat/MessageList.tsx | 59 ++++- 2 files changed, 291 insertions(+), 3 deletions(-) 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 = {