fix(desktop): surface off-screen approvals via the jump-to-bottom control (#45853)

* fix(desktop): jump-to-approval pill for off-screen approvals

A blocked approval's only response surface is the inline Run/Reject bar on
the pending tool row. When that row is scrolled out of view the session looks
stalled with no visible action. Surface a composer-anchored "Approval needed"
pill only when an approval is pending AND its inline bar is scrolled away;
clicking scrolls the bar back into view. Preserves the deliberate inline (not
modal) approval design — the pill never duplicates the approve/reject controls.

The inline bar mirrors its own viewport visibility via IntersectionObserver
(tracks scroll/resize/layout) and registers a scroll-into-view handler the pill
fires, mirroring the existing thread-scroll jump-button bridge.

Supersedes #45828.

* fix(desktop): morph jump-to-bottom into approval prompt; drop scroll bridge

Collapse the separate "jump to approval" pill into the existing
scroll-to-bottom control: when scrolled away from the bottom while an approval
is pending, it relabels to "Approval needed". A parked approval's inline
Run/Reject bar is always the bottom-most content, so the existing
scroll-to-bottom action lands the user right on it — one control, no collision.

This also fixes the layout corruption from the first cut: the pill called
native el.scrollIntoView(), which scrolls every scrollable ancestor including
the overflow:hidden chat shell containers. Those have no scrollbar to scroll
back and don't remount on session switch, so the composer stayed shoved and
the breakage persisted across sessions. Reusing requestScrollToBottom() (the
use-stick-to-bottom path) only touches the one designated scroll container.

Removes the now-unused approval-scroll store + IntersectionObserver wiring.
This commit is contained in:
brooklyn! 2026-06-13 18:07:22 -05:00 committed by GitHub
parent 4026f526d5
commit 6b76284c77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 93 additions and 5 deletions

View file

@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
function pendingApproval() {
$activeSessionId.set('sess-1')
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
}
afterEach(() => {
cleanup()
clearAllPrompts()
resetThreadScroll()
$activeSessionId.set(null)
})
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
// control's hidden (parked-at-bottom) state.
describe('ScrollToBottomButton', () => {
it('stays hidden while parked at the bottom', () => {
render(<ScrollToBottomButton />)
expect(screen.queryByRole('button')).toBeNull()
})
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
expect(screen.queryByText('Approval needed')).toBeNull()
})
it('morphs into the approval pill when scrolled up with a pending approval', () => {
pendingApproval()
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
expect(screen.getByText('Approval needed')).toBeTruthy()
})
it('does not morph while a pending approval is still in view (at bottom)', () => {
pendingApproval()
render(<ScrollToBottomButton />)
// Parked at bottom → control hidden, so it can't claim "approval needed".
expect(screen.queryByRole('button')).toBeNull()
})
it('re-arms sticky-bottom on click', () => {
const handler = vi.fn()
const stop = onScrollToBottomRequest(handler)
setThreadAtBottom(false)
render(<ScrollToBottomButton />)
fireEvent.click(screen.getByRole('button'))
expect(handler).toHaveBeenCalledTimes(1)
stop()
})
})

View file

@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
/**
@ -15,6 +16,13 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
* / background cards. Visible only while the user has scrolled meaningfully
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
*
* When the turn is BLOCKED on an approval, this same control morphs into an
* "Approval needed" pill the only response surface is the inline Run/Reject
* bar on the parked tool row, which is always the bottom-most content, so the
* existing scroll-to-bottom action lands the user right on it. One control, no
* collision, no second scroll path (native scrollIntoView would scroll
* overflow:hidden ancestors that can't scroll back and wreck the layout).
*
* Enter/exit motion lives in styles.css under `.thread-jump-button` a
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
@ -23,6 +31,11 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
export function ScrollToBottomButton() {
const { t } = useI18n()
const visible = useStore($threadJumpButtonVisible)
const request = useStore($approvalRequest)
// Scrolled away while an approval is pending → the inline Run/Reject bar is
// below the fold. Relabel so the user knows the session needs them, not just
// that there's more to read.
const approval = visible && Boolean(request)
const hasShownRef = useRef(false)
if (visible) {
@ -30,15 +43,17 @@ export function ScrollToBottomButton() {
}
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
return (
<button
aria-hidden={!visible}
aria-label={t.assistant.thread.scrollToBottom}
aria-label={label}
className={cn(
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
approval
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
!visible && 'pointer-events-none'
)}
data-state={state}
@ -52,7 +67,8 @@ export function ScrollToBottomButton() {
tabIndex={visible ? 0 : -1}
type="button"
>
<Codicon name="arrow-down" size="1rem" />
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
{approval && <span className="text-xs font-medium">{label}</span>}
</button>
)
}

View file

@ -1694,6 +1694,7 @@ export const en: Translations = {
moreOptions: 'More approval options',
allowSession: 'Allow this session',
alwaysAllowMenu: 'Always allow…',
jumpToApproval: 'Approval needed',
reject: 'Reject',
alwaysTitle: 'Always allow this command?',
alwaysDescription: pattern =>

View file

@ -1834,6 +1834,7 @@ export const ja = defineLocale({
moreOptions: 'その他の承認オプション',
allowSession: 'このセッションで許可',
alwaysAllowMenu: '常に許可…',
jumpToApproval: '承認が必要',
reject: '拒否',
alwaysTitle: 'このコマンドを常に許可しますか?',
alwaysDescription: pattern =>

View file

@ -1353,6 +1353,7 @@ export interface Translations {
moreOptions: string
allowSession: string
alwaysAllowMenu: string
jumpToApproval: string
reject: string
alwaysTitle: string
alwaysDescription: (pattern: string) => string

View file

@ -1778,6 +1778,7 @@ export const zhHant = defineLocale({
moreOptions: '更多核准選項',
allowSession: '允許本工作階段',
alwaysAllowMenu: '一律允許…',
jumpToApproval: '需要核准',
reject: '拒絕',
alwaysTitle: '一律允許此指令?',
alwaysDescription: pattern =>

View file

@ -1874,6 +1874,7 @@ export const zh: Translations = {
moreOptions: '更多审批选项',
allowSession: '允许本会话',
alwaysAllowMenu: '始终允许…',
jumpToApproval: '需要审批',
reject: '拒绝',
alwaysTitle: '始终允许此命令?',
alwaysDescription: pattern =>