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
This commit is contained in:
committed by
GitHub
parent
23243dda7a
commit
6727e2dbb9
@@ -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 { ReactNode } from 'react';
|
||||||
import type { ApprovalStatus, ToolStatus } from 'shared/types';
|
import type { ApprovalStatus, ToolStatus } from 'shared/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -13,14 +20,20 @@ import { approvalsApi } from '@/lib/api';
|
|||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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.';
|
const DEFAULT_DENIAL_REASON = 'User denied this tool use request.';
|
||||||
|
|
||||||
|
// ---------- Types ----------
|
||||||
interface PendingApprovalEntryProps {
|
interface PendingApprovalEntryProps {
|
||||||
pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>;
|
pendingStatus: Extract<ToolStatus, { status: 'pending_approval' }>;
|
||||||
executionProcessId?: string;
|
executionProcessId?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Utils ----------
|
||||||
function formatSeconds(s: number) {
|
function formatSeconds(s: number) {
|
||||||
if (s <= 0) return '0s';
|
if (s <= 0) return '0s';
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
@@ -28,113 +41,324 @@ function formatSeconds(s: number) {
|
|||||||
return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
|
return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Hooks ----------
|
||||||
|
function useAbortController() {
|
||||||
|
const ref = useRef<AbortController | null>(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<number>(() => {
|
||||||
|
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 (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center pr-8">
|
||||||
|
<CircularProgress percent={percent} />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{formatSeconds(timeLeft)} remaining</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButtons({
|
||||||
|
disabled,
|
||||||
|
isResponding,
|
||||||
|
onApprove,
|
||||||
|
onStartDeny,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
isResponding: boolean;
|
||||||
|
onApprove: () => void;
|
||||||
|
onStartDeny: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 pr-4">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onApprove}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 rounded-full p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={isResponding ? 'Submitting approval' : 'Approve'}
|
||||||
|
aria-busy={isResponding}
|
||||||
|
>
|
||||||
|
<Check className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{isResponding ? 'Submitting…' : 'Approve request'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onStartDeny}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 rounded-full p-0"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={isResponding ? 'Submitting denial' : 'Deny'}
|
||||||
|
aria-busy={isResponding}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{isResponding ? 'Submitting…' : 'Provide denial reason'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLTextAreaElement>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 bg-background px-3 py-3 text-sm">
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="Let the agent know why this request was denied..."
|
||||||
|
disabled={isResponding}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<ProgressWithTooltip
|
||||||
|
visible={timeLeft > 0}
|
||||||
|
timeLeft={timeLeft}
|
||||||
|
percent={percent}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onSubmit} disabled={isResponding}>
|
||||||
|
Deny
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Main Component ----------
|
||||||
const PendingApprovalEntry = ({
|
const PendingApprovalEntry = ({
|
||||||
pendingStatus,
|
pendingStatus,
|
||||||
executionProcessId,
|
executionProcessId,
|
||||||
children,
|
children,
|
||||||
}: PendingApprovalEntryProps) => {
|
}: PendingApprovalEntryProps) => {
|
||||||
const [timeLeft, setTimeLeft] = useState<number>(() => {
|
|
||||||
const remaining = new Date(pendingStatus.timeout_at).getTime() - Date.now();
|
|
||||||
return Math.max(0, Math.floor(remaining / 1000));
|
|
||||||
});
|
|
||||||
const [isResponding, setIsResponding] = useState(false);
|
const [isResponding, setIsResponding] = useState(false);
|
||||||
const [hasResponded, setHasResponded] = useState(false);
|
const [hasResponded, setHasResponded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
const [isEnteringReason, setIsEnteringReason] = useState(false);
|
||||||
const [denyReason, setDenyReason] = useState('');
|
const [denyReason, setDenyReason] = useState('');
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
|
const abortRef = useAbortController();
|
||||||
const denyReasonRef = useRef<HTMLTextAreaElement | null>(null);
|
const denyReasonRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const percent = useMemo(() => {
|
const { enableScope, disableScope, activeScopes } = useHotkeysContext();
|
||||||
const total = Math.max(
|
const tabNav = useContext(TabNavContext);
|
||||||
1,
|
const isLogsTabActive = tabNav ? tabNav.activeTab === 'logs' : true;
|
||||||
Math.floor(
|
const dialogScopeActive = activeScopes.includes(Scope.DIALOG);
|
||||||
(new Date(pendingStatus.timeout_at).getTime() -
|
const shouldControlScopes = isLogsTabActive && !dialogScopeActive;
|
||||||
new Date(pendingStatus.requested_at).getTime()) /
|
const approvalsScopeEnabledRef = useRef(false);
|
||||||
1000
|
const dialogScopeActiveRef = useRef(dialogScopeActive);
|
||||||
)
|
|
||||||
);
|
|
||||||
return Math.max(0, Math.min(100, Math.round((timeLeft / total) * 100)));
|
|
||||||
}, [pendingStatus.requested_at, pendingStatus.timeout_at, timeLeft]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasResponded) return;
|
dialogScopeActiveRef.current = dialogScopeActive;
|
||||||
|
}, [dialogScopeActive]);
|
||||||
|
|
||||||
const id = window.setInterval(() => {
|
const { timeLeft, percent } = useApprovalCountdown(
|
||||||
const remaining =
|
pendingStatus.requested_at,
|
||||||
new Date(pendingStatus.timeout_at).getTime() - Date.now();
|
pendingStatus.timeout_at,
|
||||||
const next = Math.max(0, Math.floor(remaining / 1000));
|
hasResponded
|
||||||
setTimeLeft(next);
|
);
|
||||||
if (next <= 0) {
|
|
||||||
window.clearInterval(id);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, [pendingStatus.timeout_at, hasResponded]);
|
|
||||||
|
|
||||||
useEffect(() => () => abortRef.current?.abort(), []);
|
|
||||||
|
|
||||||
const disabled = isResponding || hasResponded || timeLeft <= 0;
|
const disabled = isResponding || hasResponded || timeLeft <= 0;
|
||||||
|
|
||||||
const respond = async (approved: boolean, reason?: string) => {
|
const shouldEnableApprovalsScope = shouldControlScopes && !disabled;
|
||||||
if (disabled) return;
|
|
||||||
if (!executionProcessId) {
|
useEffect(() => {
|
||||||
setError('Missing executionProcessId');
|
const shouldEnable = shouldEnableApprovalsScope;
|
||||||
return;
|
|
||||||
|
if (shouldEnable && !approvalsScopeEnabledRef.current) {
|
||||||
|
enableScope(Scope.APPROVALS);
|
||||||
|
disableScope(Scope.KANBAN);
|
||||||
|
approvalsScopeEnabledRef.current = true;
|
||||||
|
} else if (!shouldEnable && approvalsScopeEnabledRef.current) {
|
||||||
|
disableScope(Scope.APPROVALS);
|
||||||
|
if (!dialogScopeActive) {
|
||||||
|
enableScope(Scope.KANBAN);
|
||||||
|
}
|
||||||
|
approvalsScopeEnabledRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsResponding(true);
|
return () => {
|
||||||
setError(null);
|
if (approvalsScopeEnabledRef.current) {
|
||||||
const controller = new AbortController();
|
disableScope(Scope.APPROVALS);
|
||||||
abortRef.current = controller;
|
if (!dialogScopeActiveRef.current) {
|
||||||
|
enableScope(Scope.KANBAN);
|
||||||
|
}
|
||||||
|
approvalsScopeEnabledRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
disableScope,
|
||||||
|
enableScope,
|
||||||
|
dialogScopeActive,
|
||||||
|
shouldEnableApprovalsScope,
|
||||||
|
]);
|
||||||
|
|
||||||
const status: ApprovalStatus = approved
|
const respond = useCallback(
|
||||||
? { status: 'approved' }
|
async (approved: boolean, reason?: string) => {
|
||||||
: { status: 'denied', reason };
|
if (disabled) return;
|
||||||
|
if (!executionProcessId) {
|
||||||
|
setError('Missing executionProcessId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
setIsResponding(true);
|
||||||
await approvalsApi.respond(
|
setError(null);
|
||||||
pendingStatus.approval_id,
|
const controller = new AbortController();
|
||||||
{
|
abortRef.current = controller;
|
||||||
execution_process_id: executionProcessId,
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
controller.signal
|
|
||||||
);
|
|
||||||
|
|
||||||
setHasResponded(true);
|
const status: ApprovalStatus = approved
|
||||||
setIsEnteringReason(false);
|
? { status: 'approved' }
|
||||||
setDenyReason('');
|
: { status: 'denied', reason };
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Approval respond failed:', e);
|
|
||||||
setError(e?.message || 'Failed to send response');
|
|
||||||
} finally {
|
|
||||||
setIsResponding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApprove = () => respond(true);
|
try {
|
||||||
const handleStartDeny = () => {
|
await approvalsApi.respond(
|
||||||
|
pendingStatus.approval_id,
|
||||||
|
{ execution_process_id: executionProcessId, status },
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
setHasResponded(true);
|
||||||
|
setIsEnteringReason(false);
|
||||||
|
setDenyReason('');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Approval respond failed:', e);
|
||||||
|
setError(e?.message || 'Failed to send response');
|
||||||
|
} finally {
|
||||||
|
setIsResponding(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[abortRef, disabled, executionProcessId, pendingStatus.approval_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApprove = useCallback(() => respond(true), [respond]);
|
||||||
|
const handleStartDeny = useCallback(() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsEnteringReason(true);
|
setIsEnteringReason(true);
|
||||||
};
|
}, [disabled]);
|
||||||
|
|
||||||
const handleCancelDeny = () => {
|
const handleCancelDeny = useCallback(() => {
|
||||||
if (isResponding) return;
|
if (isResponding) return;
|
||||||
setIsEnteringReason(false);
|
setIsEnteringReason(false);
|
||||||
setDenyReason('');
|
setDenyReason('');
|
||||||
};
|
}, [isResponding]);
|
||||||
|
|
||||||
const handleSubmitDeny = () => {
|
const handleSubmitDeny = useCallback(() => {
|
||||||
const trimmed = denyReason.trim();
|
const trimmed = denyReason.trim();
|
||||||
respond(false, trimmed || DEFAULT_DENIAL_REASON);
|
respond(false, trimmed || DEFAULT_DENIAL_REASON);
|
||||||
};
|
}, [denyReason, respond]);
|
||||||
|
|
||||||
useEffect(() => {
|
const triggerDeny = useCallback(
|
||||||
if (!hasResponded) return;
|
(event?: KeyboardEvent) => {
|
||||||
}, [hasResponded]);
|
if (!isEnteringReason || disabled || hasResponded) return;
|
||||||
|
event?.preventDefault();
|
||||||
|
handleSubmitDeny();
|
||||||
|
},
|
||||||
|
[isEnteringReason, disabled, hasResponded, handleSubmitDeny]
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeyApproveRequest(handleApprove, {
|
||||||
|
scope: Scope.APPROVALS,
|
||||||
|
when: () => shouldEnableApprovalsScope && !isEnteringReason,
|
||||||
|
preventDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyDenyApproval(triggerDeny, {
|
||||||
|
scope: Scope.APPROVALS,
|
||||||
|
when: () => shouldEnableApprovalsScope && !hasResponded,
|
||||||
|
enableOnFormTags: ['textarea', 'TEXTAREA'],
|
||||||
|
preventDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEnteringReason) return;
|
if (!isEnteringReason) return;
|
||||||
@@ -147,118 +371,52 @@ const PendingApprovalEntry = ({
|
|||||||
<div className="absolute -top-3 left-4 rounded-full border bg-background px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow-sm">
|
<div className="absolute -top-3 left-4 rounded-full border bg-background px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow-sm">
|
||||||
Awaiting approval
|
Awaiting approval
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden border">
|
<div className="overflow-hidden border">
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<div className="border-t bg-background px-2 py-1.5 text-xs sm:text-sm">
|
<div className="border-t bg-background px-2 py-1.5 text-xs sm:text-sm">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-between gap-1.5">
|
<div className="flex items-center justify-between gap-1.5 pl-4">
|
||||||
<div className="flex items-center gap-1.5 pl-4">
|
{!isEnteringReason && !hasResponded && (
|
||||||
{!isEnteringReason && (
|
<ProgressWithTooltip
|
||||||
<>
|
visible={timeLeft > 0}
|
||||||
<Tooltip>
|
timeLeft={timeLeft}
|
||||||
<TooltipTrigger asChild>
|
percent={percent}
|
||||||
<Button
|
/>
|
||||||
onClick={handleApprove}
|
)}
|
||||||
variant="ghost"
|
{!isEnteringReason && (
|
||||||
className="h-8 w-8 rounded-full p-0"
|
<ActionButtons
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={
|
isResponding={isResponding}
|
||||||
isResponding ? 'Submitting approval' : 'Approve'
|
onApprove={handleApprove}
|
||||||
}
|
onStartDeny={handleStartDeny}
|
||||||
>
|
/>
|
||||||
<Check className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{isResponding ? 'Submitting…' : 'Approve request'}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
onClick={handleStartDeny}
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 rounded-full p-0"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={
|
|
||||||
isResponding ? 'Submitting denial' : 'Deny'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{isResponding
|
|
||||||
? 'Submitting…'
|
|
||||||
: 'Provide denial reason'}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isEnteringReason && !hasResponded && timeLeft > 0 && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center pr-8">
|
|
||||||
<CircularProgress percent={percent} />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{formatSeconds(timeLeft)} remaining</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="mt-1 text-xs text-red-600">{error}</div>}
|
|
||||||
{isEnteringReason && !hasResponded && (
|
{error && (
|
||||||
<div className="mt-3 bg-background px-3 py-3 text-sm">
|
<div
|
||||||
<Textarea
|
className="mt-1 text-xs text-red-600"
|
||||||
ref={denyReasonRef}
|
role="alert"
|
||||||
value={denyReason}
|
aria-live="polite"
|
||||||
onChange={(e) => {
|
>
|
||||||
setDenyReason(e.target.value);
|
{error}
|
||||||
}}
|
|
||||||
placeholder="Let the agent know why this request was denied..."
|
|
||||||
disabled={isResponding}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelDeny}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmitDeny}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
Submit denial
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{!hasResponded && timeLeft > 0 && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center pr-2">
|
|
||||||
<CircularProgress percent={percent} />
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{formatSeconds(timeLeft)} remaining</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isEnteringReason && !hasResponded && (
|
||||||
|
<DenyReasonForm
|
||||||
|
isResponding={isResponding}
|
||||||
|
timeLeft={timeLeft}
|
||||||
|
percent={percent}
|
||||||
|
value={denyReason}
|
||||||
|
onChange={setDenyReason}
|
||||||
|
onCancel={handleCancelDeny}
|
||||||
|
onSubmit={handleSubmitDeny}
|
||||||
|
inputRef={denyReasonRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,28 +14,37 @@ export const CircularProgress: React.FC<CircularProgressProps> = ({
|
|||||||
const strokeDashoffset = circumference - (percent / 100) * circumference;
|
const strokeDashoffset = circumference - (percent / 100) * circumference;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} className="transform -rotate-90">
|
<div
|
||||||
<circle
|
className="relative inline-flex items-center justify-center"
|
||||||
cx={size / 2}
|
style={{ width: size, height: size }}
|
||||||
cy={size / 2}
|
>
|
||||||
r={radius}
|
<svg
|
||||||
stroke="currentColor"
|
width={size}
|
||||||
strokeWidth={strokeWidth}
|
height={size}
|
||||||
fill="none"
|
className="absolute inset-0 transform -rotate-90"
|
||||||
className="text-muted-foreground/20"
|
>
|
||||||
/>
|
<circle
|
||||||
<circle
|
cx={size / 2}
|
||||||
cx={size / 2}
|
cy={size / 2}
|
||||||
cy={size / 2}
|
r={radius}
|
||||||
r={radius}
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
strokeWidth={strokeWidth}
|
||||||
strokeWidth={strokeWidth}
|
fill="none"
|
||||||
fill="none"
|
className="text-muted-foreground/20"
|
||||||
strokeDasharray={circumference}
|
/>
|
||||||
strokeDashoffset={strokeDashoffset}
|
<circle
|
||||||
className="text-muted-foreground transition-all duration-1000 ease-linear"
|
cx={size / 2}
|
||||||
strokeLinecap="round"
|
cy={size / 2}
|
||||||
/>
|
r={radius}
|
||||||
</svg>
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
className="text-muted-foreground transition-all duration-1000 ease-linear"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
useKeyboardShortcutsRegistry,
|
useKeyboardShortcutsRegistry,
|
||||||
type ShortcutConfig,
|
type ShortcutConfig,
|
||||||
} from '@/contexts/keyboard-shortcuts-context';
|
} from '@/contexts/keyboard-shortcuts-context';
|
||||||
|
import type { EnableOnFormTags } from '@/keyboard/types';
|
||||||
|
|
||||||
export interface KeyboardShortcutOptions {
|
export interface KeyboardShortcutOptions {
|
||||||
enableOnContentEditable?: boolean;
|
enableOnContentEditable?: boolean;
|
||||||
|
enableOnFormTags?: EnableOnFormTags;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +20,11 @@ export function useKeyboardShortcut(
|
|||||||
const unregisterRef = useRef<(() => void) | null>(null);
|
const unregisterRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const { keys, callback, when = true, description, group, scope } = config;
|
const { keys, callback, when = true, description, group, scope } = config;
|
||||||
const { enableOnContentEditable = false, preventDefault = false } = options;
|
const {
|
||||||
|
enableOnContentEditable = false,
|
||||||
|
enableOnFormTags,
|
||||||
|
preventDefault = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
// Keep latest callback/when without forcing re-register
|
// Keep latest callback/when without forcing re-register
|
||||||
const callbackRef = useRef(callback);
|
const callbackRef = useRef(callback);
|
||||||
@@ -64,9 +70,10 @@ export function useKeyboardShortcut(
|
|||||||
{
|
{
|
||||||
enabled: true, // we gate inside handler via whenRef
|
enabled: true, // we gate inside handler via whenRef
|
||||||
enableOnContentEditable,
|
enableOnContentEditable,
|
||||||
|
enableOnFormTags,
|
||||||
preventDefault,
|
preventDefault,
|
||||||
scopes: scope ? [scope] : ['*'],
|
scopes: scope ? [scope] : ['*'],
|
||||||
},
|
},
|
||||||
[keys, scope] // handler uses refs; only rebinding when identity changes
|
[keys, scope, enableOnContentEditable, enableOnFormTags, preventDefault] // handler uses refs; only rebinding when identity changes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,3 +92,19 @@ export const useKeyToggleFullscreen = createSemanticHook(
|
|||||||
* useKeyDeleteTask(() => handleDeleteTask(selectedTask), { scope: Scope.KANBAN });
|
* useKeyDeleteTask(() => handleDeleteTask(selectedTask), { scope: Scope.KANBAN });
|
||||||
*/
|
*/
|
||||||
export const useKeyDeleteTask = createSemanticHook(Action.DELETE_TASK);
|
export const useKeyDeleteTask = createSemanticHook(Action.DELETE_TASK);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve pending approval action - typically Enter key
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useKeyApproveRequest(() => approvePendingRequest(), { scope: Scope.APPROVALS });
|
||||||
|
*/
|
||||||
|
export const useKeyApproveRequest = createSemanticHook(Action.APPROVE_REQUEST);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deny pending approval action - typically Cmd/Ctrl+Enter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useKeyDenyApproval(() => denyPendingRequest(), { scope: Scope.GLOBAL });
|
||||||
|
*/
|
||||||
|
export const useKeyDenyApproval = createSemanticHook(Action.DENY_APPROVAL);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum Scope {
|
|||||||
KANBAN = 'kanban',
|
KANBAN = 'kanban',
|
||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
EDIT_COMMENT = 'edit-comment',
|
EDIT_COMMENT = 'edit-comment',
|
||||||
|
APPROVALS = 'approvals',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Action {
|
export enum Action {
|
||||||
@@ -19,6 +20,8 @@ export enum Action {
|
|||||||
SHOW_HELP = 'show_help',
|
SHOW_HELP = 'show_help',
|
||||||
TOGGLE_FULLSCREEN = 'toggle_fullscreen',
|
TOGGLE_FULLSCREEN = 'toggle_fullscreen',
|
||||||
DELETE_TASK = 'delete_task',
|
DELETE_TASK = 'delete_task',
|
||||||
|
APPROVE_REQUEST = 'approve_request',
|
||||||
|
DENY_APPROVAL = 'deny_approval',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyBinding {
|
export interface KeyBinding {
|
||||||
@@ -148,6 +151,22 @@ export const keyBindings: KeyBinding[] = [
|
|||||||
description: 'Delete selected task',
|
description: 'Delete selected task',
|
||||||
group: 'Task Details',
|
group: 'Task Details',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Approval actions
|
||||||
|
{
|
||||||
|
action: Action.APPROVE_REQUEST,
|
||||||
|
keys: 'enter',
|
||||||
|
scopes: [Scope.APPROVALS],
|
||||||
|
description: 'Approve pending approval request',
|
||||||
|
group: 'Approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: Action.DENY_APPROVAL,
|
||||||
|
keys: ['meta+enter', 'ctrl+enter'],
|
||||||
|
scopes: [Scope.APPROVALS],
|
||||||
|
description: 'Deny pending approval request',
|
||||||
|
group: 'Approvals',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
4
frontend/src/keyboard/types.ts
Normal file
4
frontend/src/keyboard/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type FormTag = 'input' | 'textarea' | 'select';
|
||||||
|
export type EnableOnFormTags =
|
||||||
|
| boolean
|
||||||
|
| readonly (FormTag | Uppercase<FormTag>)[];
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
useKeyboardShortcut,
|
useKeyboardShortcut,
|
||||||
type KeyboardShortcutOptions,
|
type KeyboardShortcutOptions,
|
||||||
} from '@/hooks/useKeyboardShortcut';
|
} from '@/hooks/useKeyboardShortcut';
|
||||||
|
import type { EnableOnFormTags } from './types';
|
||||||
import { Action, Scope, getKeysFor, getBindingFor } from './registry';
|
import { Action, Scope, getKeysFor, getBindingFor } from './registry';
|
||||||
|
|
||||||
export interface SemanticKeyOptions {
|
export interface SemanticKeyOptions {
|
||||||
@@ -10,6 +11,7 @@ export interface SemanticKeyOptions {
|
|||||||
enabled?: boolean | (() => boolean);
|
enabled?: boolean | (() => boolean);
|
||||||
when?: boolean | (() => boolean); // Alias for enabled
|
when?: boolean | (() => boolean); // Alias for enabled
|
||||||
enableOnContentEditable?: boolean;
|
enableOnContentEditable?: boolean;
|
||||||
|
enableOnFormTags?: EnableOnFormTags;
|
||||||
preventDefault?: boolean;
|
preventDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ export function createSemanticHook<A extends Action>(action: A) {
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
when,
|
when,
|
||||||
enableOnContentEditable,
|
enableOnContentEditable,
|
||||||
|
enableOnFormTags,
|
||||||
preventDefault,
|
preventDefault,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -45,6 +48,8 @@ export function createSemanticHook<A extends Action>(action: A) {
|
|||||||
const keyboardShortcutOptions: KeyboardShortcutOptions = {};
|
const keyboardShortcutOptions: KeyboardShortcutOptions = {};
|
||||||
if (enableOnContentEditable !== undefined)
|
if (enableOnContentEditable !== undefined)
|
||||||
keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable;
|
keyboardShortcutOptions.enableOnContentEditable = enableOnContentEditable;
|
||||||
|
if (enableOnFormTags !== undefined)
|
||||||
|
keyboardShortcutOptions.enableOnFormTags = enableOnFormTags;
|
||||||
if (preventDefault !== undefined)
|
if (preventDefault !== undefined)
|
||||||
keyboardShortcutOptions.preventDefault = preventDefault;
|
keyboardShortcutOptions.preventDefault = preventDefault;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user