diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx new file mode 100644 index 000000000..f163ec848 --- /dev/null +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx @@ -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() + + expect(screen.queryByRole('button')).toBeNull() + }) + + it('is a plain jump-to-bottom control when scrolled up with no approval', () => { + setThreadAtBottom(false) + render() + + 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() + + 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() + + // 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() + + fireEvent.click(screen.getByRole('button')) + + expect(handler).toHaveBeenCalledTimes(1) + stop() + }) +}) diff --git a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx index b5d947ac1..dfe8b4e4d 100644 --- a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx +++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx @@ -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 ( ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index dc8dfc727..d9739aab9 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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 => diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index cae9539bc..aa0b736b8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1834,6 +1834,7 @@ export const ja = defineLocale({ moreOptions: 'その他の承認オプション', allowSession: 'このセッションで許可', alwaysAllowMenu: '常に許可…', + jumpToApproval: '承認が必要', reject: '拒否', alwaysTitle: 'このコマンドを常に許可しますか?', alwaysDescription: pattern => diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 44e03e87a..d89199c51 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1353,6 +1353,7 @@ export interface Translations { moreOptions: string allowSession: string alwaysAllowMenu: string + jumpToApproval: string reject: string alwaysTitle: string alwaysDescription: (pattern: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e28091a9d..eda65ad6b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1778,6 +1778,7 @@ export const zhHant = defineLocale({ moreOptions: '更多核准選項', allowSession: '允許本工作階段', alwaysAllowMenu: '一律允許…', + jumpToApproval: '需要核准', reject: '拒絕', alwaysTitle: '一律允許此指令?', alwaysDescription: pattern => diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0c073ba1e..e1082deba 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1874,6 +1874,7 @@ export const zh: Translations = { moreOptions: '更多审批选项', allowSession: '允许本会话', alwaysAllowMenu: '始终允许…', + jumpToApproval: '需要审批', reject: '拒绝', alwaysTitle: '始终允许此命令?', alwaysDescription: pattern =>