From 6727e2dbb9af69d46b264088c6ac2afc9cd238fd Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Tue, 30 Sep 2025 11:40:34 +0100 Subject: [PATCH] feat: keyboard shortcuts for approvals (#869) * wip * cmd click shortcut to deny approval * cleaner type * show percentage in progress bar * improve structure of PendingApprovalComponent * enter to approve request * disable kanban scope * fix approval scope selection --- .../PendingApprovalEntry.tsx | 512 ++++++++++++------ .../src/components/ui/circular-progress.tsx | 55 +- frontend/src/hooks/useKeyboardShortcut.ts | 11 +- frontend/src/keyboard/hooks.ts | 16 + frontend/src/keyboard/registry.ts | 19 + frontend/src/keyboard/types.ts | 4 + frontend/src/keyboard/useSemanticKey.ts | 5 + 7 files changed, 420 insertions(+), 202 deletions(-) create mode 100644 frontend/src/keyboard/types.ts diff --git a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx index 0798341b..446f561e 100644 --- a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx +++ b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx @@ -1,4 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ReactNode } from 'react'; import type { ApprovalStatus, ToolStatus } from 'shared/types'; import { Button } from '@/components/ui/button'; @@ -13,14 +20,20 @@ import { approvalsApi } from '@/lib/api'; import { Check, X } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; +import { useHotkeysContext } from 'react-hotkeys-hook'; +import { TabNavContext } from '@/contexts/TabNavigationContext'; +import { useKeyApproveRequest, useKeyDenyApproval, Scope } from '@/keyboard'; + const DEFAULT_DENIAL_REASON = 'User denied this tool use request.'; +// ---------- Types ---------- interface PendingApprovalEntryProps { pendingStatus: Extract; executionProcessId?: string; children: ReactNode; } +// ---------- Utils ---------- function formatSeconds(s: number) { if (s <= 0) return '0s'; const m = Math.floor(s / 60); @@ -28,113 +41,324 @@ function formatSeconds(s: number) { return m > 0 ? `${m}m ${rem}s` : `${rem}s`; } +// ---------- Hooks ---------- +function useAbortController() { + const ref = useRef(null); + useEffect(() => () => ref.current?.abort(), []); + return ref; +} + +function useApprovalCountdown( + requestedAt: string | number | Date, + timeoutAt: string | number | Date, + paused: boolean +) { + const totalSeconds = useMemo(() => { + const total = Math.floor( + (new Date(timeoutAt).getTime() - new Date(requestedAt).getTime()) / 1000 + ); + return Math.max(1, total); + }, [requestedAt, timeoutAt]); + + const [timeLeft, setTimeLeft] = useState(() => { + const remaining = new Date(timeoutAt).getTime() - Date.now(); + return Math.max(0, Math.floor(remaining / 1000)); + }); + + useEffect(() => { + if (paused) return; + const id = window.setInterval(() => { + const remaining = new Date(timeoutAt).getTime() - Date.now(); + const next = Math.max(0, Math.floor(remaining / 1000)); + setTimeLeft(next); + if (next <= 0) window.clearInterval(id); + }, 1000); + + return () => window.clearInterval(id); + }, [timeoutAt, paused]); + + const percent = useMemo( + () => + Math.max(0, Math.min(100, Math.round((timeLeft / totalSeconds) * 100))), + [timeLeft, totalSeconds] + ); + + return { timeLeft, percent }; +} + +// ---------- Subcomponents ---------- +function ProgressWithTooltip({ + visible, + timeLeft, + percent, +}: { + visible: boolean; + timeLeft: number; + percent: number; +}) { + if (!visible) return null; + return ( + + +
+ +
+
+ +

{formatSeconds(timeLeft)} remaining

+
+
+ ); +} + +function ActionButtons({ + disabled, + isResponding, + onApprove, + onStartDeny, +}: { + disabled: boolean; + isResponding: boolean; + onApprove: () => void; + onStartDeny: () => void; +}) { + return ( +
+ + + + + +

{isResponding ? 'Submitting…' : 'Approve request'}

+
+
+ + + + + + +

{isResponding ? 'Submitting…' : 'Provide denial reason'}

+
+
+
+ ); +} + +function DenyReasonForm({ + isResponding, + timeLeft, + percent, + value, + onChange, + onCancel, + onSubmit, + inputRef, +}: { + isResponding: boolean; + timeLeft: number; + percent: number; + value: string; + onChange: (v: string) => void; + onCancel: () => void; + onSubmit: () => void; + inputRef: React.RefObject; +}) { + return ( +
+