diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
index 007eeff831b..db8debd85c6 100644
--- a/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
@@ -6,7 +6,7 @@ import { $gateway } from '@/store/gateway'
import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
-import { PendingToolApproval } from './tool-approval'
+import { PendingApprovalFallback, PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
@@ -130,4 +130,30 @@ describe('PendingToolApproval', () => {
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
})
+
+ it('renders a floating fallback when no pending tool row is mounted', () => {
+ setRequest('rm /tmp/hermes_approval_test.txt')
+ const { container } = render()
+ const fallback = container.querySelector('[data-slot="tool-approval-fallback"]')
+
+ expect(fallback).not.toBeNull()
+ expect(within(fallback as HTMLElement).getByRole('button', { name: /Run/ })).toBeTruthy()
+ expect(within(fallback as HTMLElement).getByRole('button', { name: /Reject/ })).toBeTruthy()
+ })
+
+ it('hides the floating fallback once the inline approval bar is mounted', async () => {
+ setRequest('rm /tmp/hermes_approval_test.txt')
+
+ const { container } = render(
+ <>
+
+
+ >
+ )
+
+ await waitFor(() => {
+ expect(container.querySelector('[data-slot="tool-approval-inline"]')).not.toBeNull()
+ expect(container.querySelector('[data-slot="tool-approval-fallback"]')).toBeNull()
+ })
+ })
})
diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
index d355fda77fc..3a0bf75af5e 100644
--- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
@@ -15,11 +15,17 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { ChevronDown, Loader2 } from '@/lib/icons'
+import { AlertCircle, ChevronDown, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
-import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
+import {
+ $approvalInlineVisible,
+ $approvalRequest,
+ type ApprovalRequest,
+ clearApprovalRequest,
+ registerApprovalInlineAnchor
+} from '@/store/prompts'
import type { ToolPart } from './tool-fallback-model'
@@ -48,12 +54,47 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return null
}
- return
+ return
+}
+
+const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
+ useEffect(() => registerApprovalInlineAnchor(), [])
+
+ return
+}
+
+export const PendingApprovalFallback: FC = () => {
+ const { t } = useI18n()
+ const request = useStore($approvalRequest)
+ const inlineVisible = useStore($approvalInlineVisible)
+
+ if (!request || inlineVisible) {
+ return null
+ }
+
+ return (
+
+
+
+
+
{t.assistant.approval.jumpToApproval}
+ {request.description && (
+
{request.description}
+ )}
+
+
+
+
+ )
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
-const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
+const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline' }> = ({ request, surface }) => {
const { t } = useI18n()
const copy = t.assistant.approval
const gateway = useStore($gateway)
@@ -99,7 +140,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
setSubmitting(null)
}
},
- [busy, gateway, request.sessionId]
+ [busy, copy.gatewayDisconnected, copy.sendFailed, gateway, request.sessionId]
)
// ⌘/Ctrl+Enter → Run, Esc → Reject.
@@ -126,7 +167,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
}, [confirmAlways, respond])
return (
-
+