mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion/ui): improve permission drawer UI and logic
This commit is contained in:
@@ -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,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 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}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user