Files
qwen-code/packages/vscode-ide-companion/src/webview/App.tsx
yiliang114 e02866d06f refactor(vscode-ide-companion): 重构代码并更新文件命名
- 更新文件命名规则,使用小写字母和下划线
- 修复部分代码导入路径
- 删除未使用的 WEBVIEW_PIN_FEATURE.md 文件
2025-11-19 10:40:16 +08:00

629 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
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 { ToolCall, type ToolCallData } from './components/ToolCall.js';
import { EmptyState } from './components/EmptyState.js';
interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update';
toolCallId: string;
kind?: string;
title?: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
path?: string;
oldText?: string | null;
newText?: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
}
interface TextMessage {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
}
export const App: React.FC = () => {
const vscode = useVSCode();
const [messages, setMessages] = useState<TextMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [currentStreamContent, setCurrentStreamContent] = useState('');
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
toolCall: PermissionToolCall;
} | null>(null);
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
new Map(),
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputFieldRef = useRef<HTMLDivElement>(null);
const [showBanner, setShowBanner] = useState(true);
const handlePermissionRequest = React.useCallback(
(request: {
options: PermissionOption[];
toolCall: PermissionToolCall;
}) => {
console.log('[WebView] Permission request received:', request);
setPermissionRequest(request);
},
[],
);
const handlePermissionResponse = React.useCallback(
(optionId: string) => {
console.log('[WebView] Sending permission response:', optionId);
vscode.postMessage({
type: 'permissionResponse',
data: { optionId },
});
setPermissionRequest(null);
},
[vscode],
);
const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => {
setToolCalls((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(update.toolCallId);
if (update.type === 'tool_call') {
// New tool call - cast content to proper type
const content = update.content?.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}));
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: update.title || 'Tool Call',
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content,
locations: update.locations,
});
} else if (update.type === 'tool_call_update' && existing) {
// Update existing tool call
const updatedContent = update.content
? update.content.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}))
: undefined;
newMap.set(update.toolCallId, {
...existing,
...(update.kind && { kind: update.kind }),
...(update.title && { title: update.title }),
...(update.status && { status: update.status }),
...(updatedContent && { content: updatedContent }),
...(update.locations && { locations: update.locations }),
});
}
return newMap;
});
}, []);
useEffect(() => {
// Listen for messages from extension
const handleMessage = (event: MessageEvent) => {
const message = event.data;
switch (message.type) {
case 'conversationLoaded': {
const conversation = message.data as Conversation;
setMessages(conversation.messages);
break;
}
case 'message': {
const newMessage = message.data as TextMessage;
setMessages((prev) => [...prev, newMessage]);
break;
}
case 'streamStart':
setIsStreaming(true);
setCurrentStreamContent('');
break;
case 'streamChunk': {
const chunkData = message.data;
if (chunkData.role === 'thinking') {
// Handle thinking chunks separately if needed
setCurrentStreamContent((prev) => prev + chunkData.chunk);
} else {
setCurrentStreamContent((prev) => prev + chunkData.chunk);
}
break;
}
case 'streamEnd':
// Finalize the streamed message
if (currentStreamContent) {
const assistantMessage: TextMessage = {
role: 'assistant',
content: currentStreamContent,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, assistantMessage]);
}
setIsStreaming(false);
setCurrentStreamContent('');
break;
case 'error':
console.error('Error from extension:', message.data.message);
setIsStreaming(false);
break;
case 'permissionRequest':
// Show permission dialog
handlePermissionRequest(message.data);
break;
case 'toolCall':
case 'toolCallUpdate':
// Handle tool call updates
handleToolCallUpdate(message.data);
break;
case 'qwenSessionList': {
const sessions = message.data.sessions || [];
setQwenSessions(sessions);
// If no current session is selected and there are sessions, select the first one
if (!currentSessionId && sessions.length > 0) {
const firstSessionId =
(sessions[0].id as string) || (sessions[0].sessionId as string);
if (firstSessionId) {
setCurrentSessionId(firstSessionId);
}
}
break;
}
case 'qwenSessionSwitched':
setShowSessionSelector(false);
// Update current session ID
if (message.data.sessionId) {
setCurrentSessionId(message.data.sessionId as string);
}
// Load messages from the session
if (message.data.messages) {
setMessages(message.data.messages);
} else {
setMessages([]);
}
setCurrentStreamContent('');
setToolCalls(new Map());
break;
case 'conversationCleared':
setMessages([]);
setCurrentStreamContent('');
setToolCalls(new Map());
break;
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [
currentStreamContent,
currentSessionId,
handlePermissionRequest,
handleToolCallUpdate,
]);
useEffect(() => {
// Auto-scroll to bottom when messages change
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, currentStreamContent]);
// Load sessions on component mount
useEffect(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
}, [vscode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim() || isStreaming) {
console.log('Submit blocked:', { inputText, isStreaming });
return;
}
console.log('Sending message:', inputText);
vscode.postMessage({
type: 'sendMessage',
data: { text: inputText },
});
// Clear input field
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
};
const handleLoadQwenSessions = () => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
setShowSessionSelector(true);
};
const handleNewQwenSession = () => {
vscode.postMessage({ type: 'newQwenSession', data: {} });
setShowSessionSelector(false);
setCurrentSessionId(null);
// Clear messages in UI
setMessages([]);
setCurrentStreamContent('');
};
const handleSwitchSession = (sessionId: string) => {
if (sessionId === currentSessionId) {
return;
}
vscode.postMessage({
type: 'switchQwenSession',
data: { sessionId },
});
setCurrentSessionId(sessionId);
setShowSessionSelector(false);
};
// Check if there are any messages or active content
const hasContent =
messages.length > 0 ||
isStreaming ||
toolCalls.size > 0 ||
permissionRequest !== null;
return (
<div className="chat-container">
{showSessionSelector && (
<div className="session-selector-overlay">
<div className="session-selector">
<div className="session-selector-header">
<h3>Past Conversations</h3>
<button onClick={() => setShowSessionSelector(false)}></button>
</div>
<div className="session-selector-actions">
<button
className="new-session-button"
onClick={handleNewQwenSession}
>
New Session
</button>
</div>
<div className="session-list">
{qwenSessions.length === 0 ? (
<p className="no-sessions">No sessions available</p>
) : (
qwenSessions.map((session) => {
const sessionId =
(session.id as string) ||
(session.sessionId as string) ||
'';
const title =
(session.title as string) ||
(session.name as string) ||
'Untitled Session';
const lastUpdated =
(session.lastUpdated as string) ||
(session.startTime as string) ||
'';
const messageCount = (session.messageCount as number) || 0;
return (
<div
key={sessionId}
className="session-item"
onClick={() => handleSwitchSession(sessionId)}
>
<div className="session-title">{title}</div>
<div className="session-meta">
<span className="session-time">
{new Date(lastUpdated).toLocaleString()}
</span>
<span className="session-count">
{messageCount} messages
</span>
</div>
<div className="session-id">
{sessionId.substring(0, 8)}...
</div>
</div>
);
})
)}
</div>
</div>
</div>
)}
<div className="chat-header">
<button
className="header-conversations-button"
onClick={handleLoadQwenSessions}
title="Past conversations"
>
<span className="button-content">
<span className="button-text">Past Conversations</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="dropdown-icon"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
</span>
</button>
<div className="header-spacer"></div>
<button
className="new-session-header-button"
onClick={handleNewQwenSession}
title="New Session"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="icon-svg"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
</button>
</div>
<div className="messages-container">
{!hasContent ? (
<EmptyState />
) : (
<>
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
)}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Info Banner */}
{showBanner && (
<div className="info-banner">
<div className="banner-content">
<svg
className="banner-icon"
width="16"
height="16"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"></path>
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z"></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
></path>
</svg>
<label>
Prefer the Terminal experience?{' '}
<a href="#" className="banner-link">
Switch back in Settings.
</a>
</label>
</div>
<button
className="banner-close"
aria-label="Close banner"
onClick={() => setShowBanner(false)}
>
<svg
width="10"
height="10"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
></path>
</svg>
</button>
</div>
)}
<div className="input-form-container">
<div className="input-form-wrapper">
<form className="input-form" onSubmit={handleSubmit}>
<div className="input-banner"></div>
<div className="input-wrapper">
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="input-field-editable"
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask qwen to edit…"
onInput={(e) => {
const target = e.target as HTMLDivElement;
setInputText(target.textContent || '');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
suppressContentEditableWarning
/>
</div>
<div className="input-actions">
<button
type="button"
className="action-button edit-mode-button"
title="Qwen will ask before each edit. Click to switch modes."
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
></path>
</svg>
<span>Ask before edits</span>
</button>
<div className="action-divider"></div>
<button
type="button"
className="action-icon-button thinking-button"
title="Thinking off"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
strokeWidth="0.27"
style={{
stroke: 'var(--app-secondary-foreground)',
fill: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
</button>
<button
type="button"
className="action-icon-button command-button"
title="Show command menu (/)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
></path>
</svg>
</button>
<button
type="submit"
className="send-button-icon"
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
);
};