fix: show desktop approval fallback (#46548)

This commit is contained in:
Flownium 2026-06-22 09:57:18 +10:00 committed by GitHub
parent 84fcbbf6a9
commit 13ce811906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 97 additions and 15 deletions

View file

@ -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(<PendingApprovalFallback />)
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(
<>
<PendingToolApproval part={part('terminal')} />
<PendingApprovalFallback />
</>
)
await waitFor(() => {
expect(container.querySelector('[data-slot="tool-approval-inline"]')).not.toBeNull()
expect(container.querySelector('[data-slot="tool-approval-fallback"]')).toBeNull()
})
})
})

View file

@ -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 <ApprovalBar request={request} />
return <InlineApprovalBar request={request} />
}
const InlineApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
useEffect(() => registerApprovalInlineAnchor(), [])
return <ApprovalBar request={request} surface="inline" />
}
export const PendingApprovalFallback: FC = () => {
const { t } = useI18n()
const request = useStore($approvalRequest)
const inlineVisible = useStore($approvalInlineVisible)
if (!request || inlineVisible) {
return null
}
return (
<div
className="pointer-events-none absolute left-1/2 z-30 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2"
data-slot="tool-approval-fallback"
style={{ bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.875rem)' }}
>
<div className="pointer-events-auto rounded-xl border border-primary/30 bg-(--ui-chat-surface-background) px-3 py-2 shadow-lg backdrop-blur-xl [-webkit-backdrop-filter:blur(1rem)]">
<div className="flex min-w-0 items-center gap-2 text-sm text-primary">
<AlertCircle className="size-4 shrink-0" />
<span className="shrink-0 font-medium">{t.assistant.approval.jumpToApproval}</span>
{request.description && (
<span className="min-w-0 truncate text-(--ui-text-tertiary)">{request.description}</span>
)}
</div>
<ApprovalBar request={request} surface="floating" />
</div>
</div>
)
}
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 (
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
<div
className={cn(surface === 'inline' ? 'mt-1 ps-5' : 'mt-2')}
data-slot={surface === 'inline' ? 'tool-approval-inline' : 'tool-approval-actions'}
>
<div className="flex items-center gap-2.5">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button

View file

@ -3,6 +3,7 @@
import { useStore } from '@nanostores/react'
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { PendingApprovalFallback } from '@/components/assistant-ui/tool-approval'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -21,13 +22,12 @@ import { notifyError } from '@/store/notifications'
import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts'
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
// password and skill secret capture. (Dangerous-command / execute_code approval
// is rendered INLINE on the pending tool row instead — see
// components/assistant-ui/tool-approval.tsx — so it reads like an inline "Run"
// affordance rather than a blocking modal.) Each Python-side caller blocks the
// agent thread until the matching `*.respond` RPC lands; without a renderer the
// agent stalls until its timeout and the tool is BLOCKED (the bug this fixes —
// desktop handled clarify.request but not these). Any close path (Esc, backdrop
// password and skill secret capture. Dangerous-command / execute_code approval
// prefers the pending tool row, but also has a chat-level fallback when no row
// is mounted (remote gateway sessions can raise the request before the matching
// tool call is visible). Each Python-side caller blocks the agent thread until
// the matching `*.respond` RPC lands; without a renderer the agent stalls until
// its timeout and the tool is BLOCKED. Any close path (Esc, backdrop
// click) funnels through Radix's single `onOpenChange(false)` and maps to a
// refusal, so silence is never mistaken for consent, matching the TUI. We
// deliberately do NOT add onEscapeKeyDown / onInteractOutside handlers — they'd
@ -227,6 +227,7 @@ function SecretDialog() {
export function PromptOverlays() {
return (
<>
<PendingApprovalFallback />
<SudoDialog />
<SecretDialog />
</>

View file

@ -87,10 +87,20 @@ export interface SecretRequest extends KeyedPrompt {
const approval = keyedPromptStore<ApprovalRequest>()
const sudo = keyedPromptStore<SudoRequest>()
const secret = keyedPromptStore<SecretRequest>()
const $approvalInlineAnchorCount = atom(0)
export const $approvalRequest = approval.$active
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
export const $approvalInlineVisible = computed($approvalInlineAnchorCount, count => count > 0)
export function registerApprovalInlineAnchor(): () => void {
$approvalInlineAnchorCount.set($approvalInlineAnchorCount.get() + 1)
return () => {
$approvalInlineAnchorCount.set(Math.max(0, $approvalInlineAnchorCount.get() - 1))
}
}
export const $sudoRequest = sudo.$active
export const setSudoRequest = sudo.set
@ -107,6 +117,7 @@ export function clearAllPrompts(sessionId?: string | null): void {
approval.reset()
sudo.reset()
secret.reset()
$approvalInlineAnchorCount.set(0)
return
}