mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix(desktop): keep permission actions visible #932
This commit is contained in:
parent
78ef57d63d
commit
ace8862ff2
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user