diff --git a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx index 47c8a0b2..6a77e3f6 100644 --- a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx +++ b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx @@ -23,6 +23,7 @@ import { useHotkeysContext } from 'react-hotkeys-hook'; import { TabNavContext } from '@/contexts/TabNavigationContext'; import { useKeyApproveRequest, useKeyDenyApproval, Scope } from '@/keyboard'; import { useProject } from '@/contexts/project-context'; +import { useApprovalForm } from '@/contexts/ApprovalFormContext'; const DEFAULT_DENIAL_REASON = 'User denied this tool use request.'; @@ -177,8 +178,14 @@ const PendingApprovalEntry = ({ const [isResponding, setIsResponding] = useState(false); const [hasResponded, setHasResponded] = useState(false); const [error, setError] = useState(null); - const [isEnteringReason, setIsEnteringReason] = useState(false); - const [denyReason, setDenyReason] = useState(''); + + const { + isEnteringReason, + denyReason, + setIsEnteringReason, + setDenyReason, + clear, + } = useApprovalForm(pendingStatus.approval_id); const denyReasonRef = useRef(null); const { projectId } = useProject(); @@ -257,8 +264,7 @@ const PendingApprovalEntry = ({ status, }); setHasResponded(true); - setIsEnteringReason(false); - setDenyReason(''); + clear(); } catch (e: any) { console.error('Approval respond failed:', e); setError(e?.message || 'Failed to send response'); @@ -266,7 +272,7 @@ const PendingApprovalEntry = ({ setIsResponding(false); } }, - [disabled, executionProcessId, pendingStatus.approval_id] + [disabled, executionProcessId, pendingStatus.approval_id, clear] ); const handleApprove = useCallback(() => respond(true), [respond]); @@ -274,13 +280,12 @@ const PendingApprovalEntry = ({ if (disabled) return; setError(null); setIsEnteringReason(true); - }, [disabled]); + }, [disabled, setIsEnteringReason]); const handleCancelDeny = useCallback(() => { if (isResponding) return; - setIsEnteringReason(false); - setDenyReason(''); - }, [isResponding]); + clear(); + }, [isResponding, clear]); const handleSubmitDeny = useCallback(() => { const trimmed = denyReason.trim(); diff --git a/frontend/src/components/logs/VirtualizedList.tsx b/frontend/src/components/logs/VirtualizedList.tsx index 4a180b4f..b2673dc7 100644 --- a/frontend/src/components/logs/VirtualizedList.tsx +++ b/frontend/src/components/logs/VirtualizedList.tsx @@ -17,6 +17,7 @@ import { } from '@/hooks/useConversationHistory'; import { Loader2 } from 'lucide-react'; import { TaskAttempt } from 'shared/types'; +import { ApprovalFormProvider } from '@/contexts/ApprovalFormContext'; interface VirtualizedListProps { attempt: TaskAttempt; @@ -107,7 +108,7 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => { const messageListContext = useMemo(() => ({ attempt }), [attempt]); return ( - <> + @@ -129,7 +130,7 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {

Loading History

)} - +
); }; diff --git a/frontend/src/contexts/ApprovalFormContext.tsx b/frontend/src/contexts/ApprovalFormContext.tsx new file mode 100644 index 00000000..0c20273b --- /dev/null +++ b/frontend/src/contexts/ApprovalFormContext.tsx @@ -0,0 +1,104 @@ +import { + createContext, + useContext, + useState, + ReactNode, + useCallback, +} from 'react'; + +interface ApprovalFormState { + isEnteringReason: boolean; + denyReason: string; +} + +interface ApprovalFormStateMap { + [approvalId: string]: ApprovalFormState; +} + +interface ApprovalFormContextType { + getState: (approvalId: string) => ApprovalFormState; + setState: (approvalId: string, partial: Partial) => void; + clear: (approvalId: string) => void; +} + +const ApprovalFormContext = createContext(null); + +const defaultState: ApprovalFormState = { + isEnteringReason: false, + denyReason: '', +}; + +export function useApprovalForm(approvalId: string) { + const context = useContext(ApprovalFormContext); + if (!context) { + throw new Error('useApprovalForm must be used within ApprovalFormProvider'); + } + + const state = context.getState(approvalId); + + const setIsEnteringReason = useCallback( + (value: boolean) => + context.setState(approvalId, { isEnteringReason: value }), + [approvalId, context] + ); + + const setDenyReason = useCallback( + (value: string) => context.setState(approvalId, { denyReason: value }), + [approvalId, context] + ); + + const clear = useCallback( + () => context.clear(approvalId), + [approvalId, context] + ); + + return { + isEnteringReason: state.isEnteringReason, + denyReason: state.denyReason, + setIsEnteringReason, + setDenyReason, + clear, + }; +} + +export function ApprovalFormProvider({ children }: { children: ReactNode }) { + const [stateMap, setStateMap] = useState({}); + + const getState = useCallback( + (approvalId: string): ApprovalFormState => { + return stateMap[approvalId] ?? defaultState; + }, + [stateMap] + ); + + const setState = useCallback( + (approvalId: string, partial: Partial) => { + setStateMap((prev) => { + const current = prev[approvalId] ?? defaultState; + const updated = { ...current, ...partial }; + return { ...prev, [approvalId]: updated }; + }); + }, + [] + ); + + const clear = useCallback((approvalId: string) => { + setStateMap((prev) => { + const newMap = { ...prev }; + delete newMap[approvalId]; + return newMap; + }); + }, []); + + return ( + + {children} + + ); +}