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 [customMessage, setCustomMessage] = useState('');
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);
// 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
const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
const fileName =
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
const fileName = getAffectedFileName();
return (
<>
Allow write to{' '}
Make this edit to{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
@@ -51,8 +74,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
return 'Allow this bash command?';
}
if (toolCall.kind === 'read') {
const fileName =
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
const fileName = getAffectedFileName();
return (
<>
Allow read from{' '}
@@ -126,6 +148,13 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
}
}, [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;
}
@@ -135,7 +164,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
{/* Main container */}
<div
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={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
@@ -149,11 +178,25 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
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="font-bold text-[var(--app-primary-foreground)] mb-1">
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
{getTitle()}
</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>
{/* Options */}
@@ -164,10 +207,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
return (
<button
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
? '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)]'
: '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:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
}`}
onClick={() => onResponse(option.optionId)}
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">
{index + 1}
</span>
{/* Option text */}
<span className="font-semibold">{option.name}</span>
@@ -187,70 +229,79 @@ 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 rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return (
<div
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
? '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)]'
: '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)]'
}`}
onMouseEnter={() => setFocusedIndex(options.length)}
onClick={() => customInputRef.current?.focus()}
>
{/* 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}
</span>
{/* Input field */}
<input
ref={customInputRef as React.RefObject<HTMLInputElement>}
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={() => setFocusedIndex(options.length)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
customMessage.trim()
) {
e.preventDefault();
const rejectOption = options.find((o) =>
o.kind.includes('reject'),
);
if (rejectOption) {
onResponse(rejectOption.optionId);
}
}
}}
/>
</div>
<CustomMessageInputRow
isFocused={isFocused}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
onFocusRow={() => setFocusedIndex(options.length)}
onSubmitReject={() => {
if (rejectOptionId) onResponse(rejectOptionId);
}}
inputRef={customInputRef}
/>
);
})()}
</div>
</div>
<style>{`
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`}</style>
{/* 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
ref={inputRef}
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();
}
}}
/>
</div>
);