/** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useEffect, useState, useRef } from 'react'; import type { PermissionOption, ToolCall } from './PermissionRequest.js'; interface PermissionDrawerProps { isOpen: boolean; options: PermissionOption[]; toolCall: ToolCall; onResponse: (optionId: string) => void; onClose?: () => void; } export const PermissionDrawer: React.FC = ({ isOpen, options, toolCall, onResponse, onClose, }) => { const [focusedIndex, setFocusedIndex] = useState(0); const [customMessage, setCustomMessage] = useState(''); const containerRef = useRef(null); // Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting const customInputRef = useRef(null); console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); // Prefer file name from locations, fall back to content[].path if present const getAffectedFileName = (): string => { const fromLocations = toolCall.locations?.[0]?.path; if (fromLocations) { return fromLocations.split('/').pop() || fromLocations; } // Some tool calls (e.g. write/edit with diff content) only include path in content const fromContent = Array.isArray(toolCall.content) ? ( toolCall.content.find( (c: unknown) => typeof c === 'object' && c !== null && 'path' in (c as Record), ) as { path?: unknown } | undefined )?.path : undefined; if (typeof fromContent === 'string' && fromContent.length > 0) { return fromContent.split('/').pop() || fromContent; } return 'file'; }; // Get the title for the permission request const getTitle = () => { if (toolCall.kind === 'edit' || toolCall.kind === 'write') { const fileName = getAffectedFileName(); return ( <> Make this edit to{' '} {fileName} ? ); } if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { return 'Allow this bash command?'; } if (toolCall.kind === 'read') { const fileName = getAffectedFileName(); return ( <> Allow read from{' '} {fileName} ? ); } return toolCall.title || 'Permission Required'; }; // Handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) { return; } // Number keys 1-9 for quick select const numMatch = e.key.match(/^[1-9]$/); if ( numMatch && !customInputRef.current?.contains(document.activeElement) ) { const index = parseInt(e.key, 10) - 1; if (index < options.length) { e.preventDefault(); onResponse(options[index].optionId); } return; } // Arrow keys for navigation if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const totalItems = options.length + 1; // +1 for custom input if (e.key === 'ArrowDown') { setFocusedIndex((prev) => (prev + 1) % totalItems); } else { setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); } } // Enter to select if ( e.key === 'Enter' && !customInputRef.current?.contains(document.activeElement) ) { e.preventDefault(); if (focusedIndex < options.length) { onResponse(options[focusedIndex].optionId); } } // Escape to cancel permission and close (align with CLI behavior) if (e.key === 'Escape') { e.preventDefault(); const rejectOptionId = options.find((o) => o.kind.includes('reject'))?.optionId || options.find((o) => o.optionId === 'cancel')?.optionId || 'cancel'; onResponse(rejectOptionId); if (onClose) { onClose(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, options, onResponse, onClose, focusedIndex]); // Focus container when opened useEffect(() => { if (isOpen && containerRef.current) { containerRef.current.focus(); } }, [isOpen]); // Reset focus to the first option when the drawer opens or the options change useEffect(() => { if (isOpen) { setFocusedIndex(0); } }, [isOpen, options.length]); if (!isOpen) { return null; } return (
{/* Main container */}
{/* Background layer */}
{/* Title + Description (from toolCall.title) */}
{getTitle()}
{(toolCall.kind === 'edit' || toolCall.kind === 'write' || toolCall.kind === 'read' || toolCall.kind === 'execute' || toolCall.kind === 'bash') && toolCall.title && (
{toolCall.title}
)}
{/* Options */}
{options.map((option, index) => { const isFocused = focusedIndex === index; return ( ); })} {/* Custom message input (extracted component) */} {(() => { const isFocused = focusedIndex === options.length; const rejectOptionId = options.find((o) => o.kind.includes('reject'), )?.optionId; return ( setFocusedIndex(options.length)} onSubmitReject={() => { if (rejectOptionId) { onResponse(rejectOptionId); } }} inputRef={customInputRef} /> ); })()}
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
); }; /** * CustomMessageInputRow: Reusable custom input row component (without hooks) */ interface CustomMessageInputRowProps { isFocused: boolean; customMessage: string; setCustomMessage: (val: string) => void; onFocusRow: () => void; // Set focus when mouse enters or input box is focused onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option) inputRef: React.RefObject; } const CustomMessageInputRow: React.FC = ({ isFocused, customMessage, setCustomMessage, onFocusRow, onSubmitReject, inputRef, }) => (
inputRef.current?.focus()} > | undefined} type="text" placeholder="Tell Qwen what to do instead" spellCheck={false} className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" style={{ color: 'var(--app-input-foreground)' }} value={customMessage} onChange={(e) => setCustomMessage(e.target.value)} onFocus={onFocusRow} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { e.preventDefault(); onSubmitReject(); } }} />
);