feat(vscode-ide-companion/ui): improve permission drawer UI and logic

This commit is contained in:
yiliang114
2025-12-05 18:03:29 +08:00
parent 75fd2a5dcc
commit 13aa4b03c7

View File

@@ -29,17 +29,40 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
const [focusedIndex, setFocusedIndex] = useState(0); const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState(''); const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const customInputRef = useRef<HTMLDivElement>(null); // 将自定义输入的 ref 类型修正为 HTMLInputElement避免后续强转
const customInputRef = useRef<HTMLInputElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); 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<string, unknown>),
) 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 // Get the title for the permission request
const getTitle = () => { const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') { if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
const fileName = const fileName = getAffectedFileName();
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
return ( return (
<> <>
Allow write to{' '} Make this edit to{' '}
<span className="font-mono text-[var(--app-primary-foreground)]"> <span className="font-mono text-[var(--app-primary-foreground)]">
{fileName} {fileName}
</span> </span>
@@ -51,8 +74,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
return 'Allow this bash command?'; return 'Allow this bash command?';
} }
if (toolCall.kind === 'read') { if (toolCall.kind === 'read') {
const fileName = const fileName = getAffectedFileName();
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
return ( return (
<> <>
Allow read from{' '} Allow read from{' '}
@@ -126,6 +148,13 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
} }
}, [isOpen]); }, [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) { if (!isOpen) {
return null; return null;
} }
@@ -135,7 +164,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
{/* Main container */} {/* Main container */}
<div <div
ref={containerRef} ref={containerRef}
className="relative flex flex-col rounded-large border p-2 outline-none animate-[slideUp_0.2s_ease-out]" className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
style={{ style={{
backgroundColor: 'var(--app-input-secondary-background)', backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)', borderColor: 'var(--app-input-border)',
@@ -149,11 +178,25 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
style={{ backgroundColor: 'var(--app-input-background)' }} style={{ backgroundColor: 'var(--app-input-background)' }}
/> />
{/* Title */} {/* Title + Description (from toolCall.title) */}
<div className="relative z-[1] px-1 text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0"> <div className="relative z-[1] px-1 text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
<div className="font-bold text-[var(--app-primary-foreground)] mb-1"> <div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
{getTitle()} {getTitle()}
</div> </div>
{(toolCall.kind === 'edit' ||
toolCall.kind === 'write' ||
toolCall.kind === 'read' ||
toolCall.kind === 'execute' ||
toolCall.kind === 'bash') &&
toolCall.title && (
<div
/* 13px常规字重正常空白折行 + 长词断行;最多 3 行溢出省略 */
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
title={toolCall.title}
>
{toolCall.title}
</div>
)}
</div> </div>
{/* Options */} {/* Options */}
@@ -164,10 +207,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
return ( return (
<button <button
key={option.optionId} key={option.optionId}
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] ${ className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-input-background)] ${
isFocused isFocused
? 'text-[var(--app-list-active-foreground)] hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0 hover:border-[var(--app-button-background)]' ? 'text-[var(--app-list-active-foreground)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0 hover:border-[var(--app-button-background)]' : 'hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
}`} }`}
onClick={() => onResponse(option.optionId)} onClick={() => onResponse(option.optionId)}
onMouseEnter={() => setFocusedIndex(index)} onMouseEnter={() => setFocusedIndex(index)}
@@ -177,7 +220,6 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold"> <span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold">
{index + 1} {index + 1}
</span> </span>
{/* Option text */} {/* Option text */}
<span className="font-semibold">{option.name}</span> <span className="font-semibold">{option.name}</span>
@@ -187,28 +229,65 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
); );
})} })}
{/* Custom message input (styled consistently with option items) */} {/* Custom message input (extracted component) */}
{(() => { {(() => {
const isFocused = focusedIndex === options.length; const isFocused = focusedIndex === options.length;
const rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return ( return (
<div <CustomMessageInputRow
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 cursor-text text-[var(--app-primary-foreground)] ${ isFocused={isFocused}
isFocused customMessage={customMessage}
? 'text-[var(--app-list-active-foreground)] hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0 hover:border-[var(--app-button-background)]' setCustomMessage={setCustomMessage}
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0 hover:border-[var(--app-button-background)]' onFocusRow={() => setFocusedIndex(options.length)}
}`} onSubmitReject={() => {
onMouseEnter={() => setFocusedIndex(options.length)} if (rejectOptionId) onResponse(rejectOptionId);
onClick={() => customInputRef.current?.focus()} }}
> inputRef={customInputRef}
{/* Number badge (N+1) */} />
{/* Plain number badge without hover background */} );
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded"> })()}
{options.length + 1} </div>
</span> </div>
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
</div>
);
};
/**
* CustomMessageInputRow: 复用的自定义输入行组件(无 hooks
*/
interface CustomMessageInputRowProps {
isFocused: boolean;
customMessage: string;
setCustomMessage: (val: string) => void;
onFocusRow: () => void; // 鼠标移入或输入框 focus 时设置焦点
onSubmitReject: () => void; // Enter 提交时触发(选择 reject 选项)
inputRef: React.RefObject<HTMLInputElement>;
}
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
isFocused,
customMessage,
setCustomMessage,
onFocusRow,
onSubmitReject,
inputRef,
}) => (
<div
// 无过渡hover 样式立即生效;输入行不加 hover 背景,也不加粗文字
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`}
onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()}
>
{/* 输入行不显示序号徽标 */}
{/* Input field */} {/* Input field */}
<input <input
ref={customInputRef as React.RefObject<HTMLInputElement>} ref={inputRef}
type="text" type="text"
placeholder="Tell Qwen what to do instead" placeholder="Tell Qwen what to do instead"
spellCheck={false} spellCheck={false}
@@ -216,41 +295,13 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
style={{ color: 'var(--app-input-foreground)' }} style={{ color: 'var(--app-input-foreground)' }}
value={customMessage} value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)} onChange={(e) => setCustomMessage(e.target.value)}
onFocus={() => setFocusedIndex(options.length)} onFocus={onFocusRow}
onKeyDown={(e) => { onKeyDown={(e) => {
if ( if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.key === 'Enter' &&
!e.shiftKey &&
customMessage.trim()
) {
e.preventDefault(); e.preventDefault();
const rejectOption = options.find((o) => onSubmitReject();
o.kind.includes('reject'),
);
if (rejectOption) {
onResponse(rejectOption.optionId);
}
} }
}} }}
/> />
</div> </div>
); );
})()}
</div>
</div>
<style>{`
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`}</style>
</div>
);
};