mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix: show desktop approval fallback (#46548)
This commit is contained in:
parent
84fcbbf6a9
commit
13ce811906
4 changed files with 97 additions and 15 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue