/** * @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([]); const [inputText, setInputText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [currentStreamContent, setCurrentStreamContent] = useState(''); const [qwenSessions, setQwenSessions] = useState< Array> >([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [showSessionSelector, setShowSessionSelector] = useState(false); const [permissionRequest, setPermissionRequest] = useState<{ options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); const [toolCalls, setToolCalls] = useState>( new Map(), ); const messagesEndRef = useRef(null); const inputFieldRef = useRef(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 (
{showSessionSelector && (

Past Conversations

{qwenSessions.length === 0 ? (

No sessions available

) : ( 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 (
handleSwitchSession(sessionId)} >
{title}
{new Date(lastUpdated).toLocaleString()} {messageCount} messages
{sessionId.substring(0, 8)}...
); }) )}
)}
{!hasContent ? ( ) : ( <> {messages.map((msg, index) => (
{msg.content}
{new Date(msg.timestamp).toLocaleTimeString()}
))} {/* Tool Calls */} {Array.from(toolCalls.values()).map((toolCall) => ( ))} {/* Permission Request */} {permissionRequest && ( )} {isStreaming && currentStreamContent && (
{currentStreamContent}
)}
)}
{/* Info Banner */} {showBanner && (
)}
{ const target = e.target as HTMLDivElement; setInputText(target.textContent || ''); }} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }} suppressContentEditableWarning />
); };