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
index dc94fee803..00ef2006ab 100644
--- a/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
+++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
@@ -27,6 +27,10 @@ afterEach(() => {
}
$activeSessionId.set(null)
+
+ for (const stack of document.querySelectorAll('[data-approval-stack]')) {
+ stack.remove()
+ }
})
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
@@ -144,4 +148,52 @@ describe('ScrollToBottomButton', () => {
expect(handler).toHaveBeenCalledTimes(1)
stop()
})
+
+ it('scrolls to the session approval stack instead of the bottom when one is pending', () => {
+ pendingApproval()
+ setThreadAtBottom(false, 'sess-1')
+ const bottomHandler = vi.fn()
+ const stopBottom = onScrollToBottomRequest(bottomHandler, 'sess-1')
+
+ const stack = document.createElement('div')
+ stack.setAttribute('data-approval-stack', '')
+ stack.setAttribute('data-session-id', 'sess-1')
+ const scrollIntoView = vi.fn()
+ stack.scrollIntoView = scrollIntoView
+ document.body.appendChild(stack)
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Approval needed' }))
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
+ expect(bottomHandler).not.toHaveBeenCalled()
+
+ stopBottom()
+ stack.remove()
+ })
+
+ it('does not jump into a sibling session’s approval stack', () => {
+ pendingApproval()
+ setThreadAtBottom(false, 'sess-1')
+
+ const otherStack = document.createElement('div')
+ otherStack.setAttribute('data-approval-stack', '')
+ otherStack.setAttribute('data-session-id', 'sess-other')
+ const otherScrollIntoView = vi.fn()
+ otherStack.scrollIntoView = otherScrollIntoView
+ document.body.appendChild(otherStack)
+
+ const bottomHandler = vi.fn()
+ const stopBottom = onScrollToBottomRequest(bottomHandler, 'sess-1')
+
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Approval needed' }))
+
+ expect(otherScrollIntoView).not.toHaveBeenCalled()
+ // No stack tagged for this session exists, so it falls back to the bottom.
+ expect(bottomHandler).toHaveBeenCalledTimes(1)
+
+ stopBottom()
+ otherStack.remove()
+ })
})
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 b5c084bc8a..549f68e377 100644
--- a/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
+++ b/apps/desktop/src/app/chat/scroll-to-bottom-button.tsx
@@ -18,6 +18,28 @@ import {
import { useComposerSurfaceId } from './composer/scope'
+// Attribute-safe selector fragment. jsdom (vitest) does not ship `CSS.escape`.
+const cssEscape = (value: string): string => {
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
+ return CSS.escape(value)
+ }
+
+ return value.replace(/[^a-zA-Z0-9_:-]/g, ch => `\\${ch}`)
+}
+
+// The pending-approval stack renders once per pane, tagged with the owning
+// session so a split view can't jump one pane's arrow into a sibling's
+// approval. `nearest` keeps this a minimal scroll within the transcript's own
+// scroll container instead of an unqualified scrollIntoView, which would also
+// nudge any overflow-hidden ancestor's programmatic scroll offset.
+function findSessionApprovalStack(sessionId: string | null): HTMLElement | null {
+ if (!sessionId) {
+ return null
+ }
+
+ return document.querySelector(`[data-approval-stack][data-session-id="${cssEscape(sessionId)}"]`)
+}
+
/**
* Floating "jump to bottom" control. Sits centered just above the composer,
* clearing the out-of-flow status stack via the same measured-height CSS vars
@@ -27,8 +49,10 @@ import { useComposerSurfaceId } from './composer/scope'
* away from the bottom, with an animated count of messages below the viewport.
* Clicking re-arms sticky-bottom and pins the viewport.
*
- * While approvals are pending, relabel this control to lead back to the
- * transcript-owned stack using the existing scroll path.
+ * While an approval is pending, relabel this control and, instead of jumping
+ * to the transcript's true bottom (which can overshoot a mid-transcript
+ * approval once newer content lands below it), scroll directly to the
+ * session's own approval stack.
*
* 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
@@ -86,6 +110,15 @@ export function ScrollToBottomButton({ sessionId }: { sessionId: string | null }
data-state={state}
onClick={() => {
triggerHaptic('selection')
+
+ const approvalStack = visibleApproval ? findSessionApprovalStack(request?.sessionId ?? null) : null
+
+ if (approvalStack) {
+ approvalStack.scrollIntoView({ block: 'nearest' })
+
+ return
+ }
+
requestScrollToBottom(scrollSessionId)
}}
style={{
diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx
index 705a271a84..0ae493b35c 100644
--- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx
@@ -64,6 +64,7 @@ export const PendingApprovalStack: FC = () => {
)}
data-approval-placement={placement}
data-approval-stack=""
+ data-session-id={sessionId ?? undefined}
data-slot="tool-approval-stack"
initial={false}
transition={reduced || requests.length ? { duration: 0 } : { duration: 0.22, ease: 'easeInOut' }}