feat(vscode-ide-companion): 优化权限请求组件并添加错误处理功能

- 移动权限请求组件到抽屉中,优化用户体验
- 为权限选项添加编号,提高可识别性
- 实现错误对象的特殊处理,提取更有意义的错误信息
- 优化工具调用错误内容的展示,提高错误信息的可读性
This commit is contained in:
yiliang114
2025-11-20 00:01:18 +08:00
parent 018990b7f6
commit e81255e589
6 changed files with 342 additions and 20 deletions

View File

@@ -1035,6 +1035,26 @@ button {
flex: 1;
}
.permission-option-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
color: var(--app-secondary-foreground);
background-color: var(--app-list-hover-background);
border-radius: 4px;
margin-right: 4px;
}
.permission-option.selected .permission-option-number {
color: var(--app-qwen-ivory);
background-color: var(--app-qwen-orange);
}
.permission-always-badge {
font-size: 12px;
}

View File

@@ -8,10 +8,10 @@ import React, { useState, useEffect, useRef } from 'react';
import { useVSCode } from './hooks/useVSCode.js';
import type { Conversation } from '../storage/conversationStore.js';
import {
PermissionRequest,
type PermissionOption,
type ToolCall as PermissionToolCall,
} from './components/PermissionRequest.js';
import { PermissionDrawer } from './components/PermissionDrawer.js';
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
import { EmptyState } from './components/EmptyState.js';
@@ -624,11 +624,7 @@ export const App: React.FC = () => {
};
// Check if there are any messages or active content
const hasContent =
messages.length > 0 ||
isStreaming ||
toolCalls.size > 0 ||
permissionRequest !== null;
const hasContent = messages.length > 0 || isStreaming || toolCalls.size > 0;
return (
<div className="chat-container">
@@ -810,15 +806,6 @@ export const App: React.FC = () => {
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
)}
{/* Loading/Waiting Message - in message list */}
{isWaitingForResponse && loadingMessage && (
<div className="message assistant waiting-message">
@@ -1003,6 +990,17 @@ export const App: React.FC = () => {
</form>
</div>
</div>
{/* Permission Drawer - Cursor style */}
{permissionRequest && (
<PermissionDrawer
isOpen={!!permissionRequest}
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
onClose={() => setPermissionRequest(null)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Permission Drawer - Bottom sheet style for permission requests
*/
/* Backdrop overlay */
.permission-drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 998;
animation: fadeIn 0.2s ease-in-out;
}
/* Drawer container - bottom sheet style */
.permission-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
max-height: 70vh;
background-color: var(--app-primary-background);
border-top: 1px solid var(--app-primary-border-color);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
z-index: 999;
display: flex;
flex-direction: column;
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2);
animation: slideUpFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpFromBottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Drawer header */
.permission-drawer-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--app-primary-border-color);
background-color: var(--app-header-background);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
flex-shrink: 0;
}
.permission-drawer-title {
font-size: 14px;
font-weight: 600;
color: var(--app-primary-foreground);
margin: 0;
}
.permission-drawer-close {
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: var(--app-secondary-foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s, color 0.15s;
}
.permission-drawer-close:hover {
background-color: var(--app-ghost-button-hover-background);
color: var(--app-primary-foreground);
}
/* Drawer content */
.permission-drawer-content {
flex: 1;
overflow-y: auto;
padding: 20px;
min-height: 0;
}
/* Override permission card styles when in drawer */
.permission-drawer-content .permission-request-card {
border: none;
margin: 0;
background: transparent;
box-shadow: none;
}
.permission-drawer-content .permission-card-body {
padding: 0;
}
/* Scrollbar for drawer content */
.permission-drawer-content::-webkit-scrollbar {
width: 8px;
}
.permission-drawer-content::-webkit-scrollbar-track {
background: transparent;
}
.permission-drawer-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.permission-drawer-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Add a drag handle indicator at the top */
.permission-drawer-header::before {
content: '';
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 4px;
background-color: var(--app-secondary-foreground);
opacity: 0.3;
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.permission-drawer {
max-height: 85vh;
}
}

View File

@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect } from 'react';
import {
PermissionRequest,
type PermissionOption,
type ToolCall,
} from './PermissionRequest.js';
import './PermissionDrawer.css';
interface PermissionDrawerProps {
isOpen: boolean;
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
onClose?: () => void;
}
/**
* Permission drawer component - displays permission requests in a bottom sheet
* @param isOpen - Whether the drawer is open
* @param options - Permission options to display
* @param toolCall - Tool call information
* @param onResponse - Callback when user responds
* @param onClose - Optional callback when drawer closes
*/
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
isOpen,
options,
toolCall,
onResponse,
onClose,
}) => {
// Close drawer on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
// Close on Escape
if (e.key === 'Escape' && onClose) {
onClose();
return;
}
// Quick select with number keys (1-9)
const numMatch = e.key.match(/^[1-9]$/);
if (numMatch) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
onResponse(options[index].optionId);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, options, onResponse]);
if (!isOpen) {
return null;
}
return (
<>
{/* Backdrop */}
<div className="permission-drawer-backdrop" onClick={onClose} />
{/* Drawer */}
<div className="permission-drawer">
<div className="permission-drawer-header">
<h3 className="permission-drawer-title">Permission Required</h3>
{onClose && (
<button
className="permission-drawer-close"
onClick={onClose}
aria-label="Close drawer"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 2L14 14M2 14L14 2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
<div className="permission-drawer-content">
<PermissionRequest
options={options}
toolCall={toolCall}
onResponse={onResponse}
/>
</div>
</div>
</>
);
};

View File

@@ -150,7 +150,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
<div className="permission-options-label">Choose an action:</div>
<div className="permission-options-list">
{options && options.length > 0 ? (
options.map((option) => {
options.map((option, index) => {
const isSelected = selected === option.optionId;
const isAllow = option.kind.includes('allow');
const isAlways = option.kind.includes('always');
@@ -171,6 +171,9 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
className="permission-radio"
/>
<span className="permission-option-content">
<span className="permission-option-number">
{index + 1}
</span>
{isAlways && (
<span className="permission-always-badge"></span>
)}

View File

@@ -18,6 +18,15 @@ export const formatValue = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
// Handle Error objects specially
if (value instanceof Error) {
return value.message || value.toString();
}
// Handle error-like objects with message property
if (typeof value === 'object' && value !== null && 'message' in value) {
const errorObj = value as { message?: string; stack?: string };
return errorObj.message || String(value);
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
@@ -84,10 +93,34 @@ export const groupContent = (content?: ToolCallContent[]): GroupedContent => {
// Handle error content
if (contentObj.type === 'error' || 'error' in contentObj) {
const errorMsg =
formatValue(contentObj.error) ||
formatValue(contentObj.text) ||
'An error occurred';
// Try to extract meaningful error message
let errorMsg = '';
// Check if error is a string
if (typeof contentObj.error === 'string') {
errorMsg = contentObj.error;
}
// Check if error has a message property
else if (
contentObj.error &&
typeof contentObj.error === 'object' &&
'message' in contentObj.error
) {
errorMsg = (contentObj.error as { message: string }).message;
}
// Try text field
else if (contentObj.text) {
errorMsg = formatValue(contentObj.text);
}
// Format the error object itself
else if (contentObj.error) {
errorMsg = formatValue(contentObj.error);
}
// Fallback
else {
errorMsg = 'An error occurred';
}
errors.push(errorMsg);
}
// Handle text content