/** * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useVSCode } from './hooks/useVSCode.js'; import type { Conversation } from '../storage/conversationStore.js'; import { 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 { hasToolCallOutput } from './components/toolcalls/shared/utils.js'; import { InProgressToolCall } from './components/InProgressToolCall.js'; import { EmptyState } from './components/EmptyState.js'; import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js'; import { CompletionMenu, type CompletionItem, } from './components/CompletionMenu.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { SaveSessionDialog } from './components/SaveSessionDialog.js'; import { InfoBanner } from './components/InfoBanner.js'; import { ChatHeader } from './components/ui/ChatHeader.js'; import { UserMessage, AssistantMessage, ThinkingMessage, StreamingMessage, WaitingMessage, } from './components/messages/index.js'; import { InputForm } from './components/InputForm.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; fileContext?: { fileName: string; filePath: string; startLine?: number; endLine?: number; }; } // Loading messages from Claude Code CLI // Source: packages/cli/src/ui/hooks/usePhraseCycler.ts const WITTY_LOADING_PHRASES = [ "I'm Feeling Lucky", 'Shipping awesomeness... ', 'Painting the serifs back on...', 'Navigating the slime mold...', 'Consulting the digital spirits...', 'Reticulating splines...', 'Warming up the AI hamsters...', 'Asking the magic conch shell...', 'Generating witty retort...', 'Polishing the algorithms...', "Don't rush perfection (or my code)...", 'Brewing fresh bytes...', 'Counting electrons...', 'Engaging cognitive processors...', 'Checking for syntax errors in the universe...', 'One moment, optimizing humor...', 'Shuffling punchlines...', 'Untangling neural nets...', 'Compiling brilliance...', 'Loading wit.exe...', 'Summoning the cloud of wisdom...', 'Preparing a witty response...', "Just a sec, I'm debugging reality...", 'Confuzzling the options...', 'Tuning the cosmic frequencies...', 'Crafting a response worthy of your patience...', 'Compiling the 1s and 0s...', 'Resolving dependencies... and existential crises...', 'Defragmenting memories... both RAM and personal...', 'Rebooting the humor module...', 'Caching the essentials (mostly cat memes)...', 'Optimizing for ludicrous speed', "Swapping bits... don't tell the bytes...", 'Garbage collecting... be right back...', 'Assembling the interwebs...', 'Converting coffee into code...', 'Updating the syntax for reality...', 'Rewiring the synapses...', 'Looking for a misplaced semicolon...', "Greasin' the cogs of the machine...", 'Pre-heating the servers...', 'Calibrating the flux capacitor...', 'Engaging the improbability drive...', 'Channeling the Force...', 'Aligning the stars for optimal response...', 'So say we all...', 'Loading the next great idea...', "Just a moment, I'm in the zone...", 'Preparing to dazzle you with brilliance...', "Just a tick, I'm polishing my wit...", "Hold tight, I'm crafting a masterpiece...", "Just a jiffy, I'm debugging the universe...", "Just a moment, I'm aligning the pixels...", "Just a sec, I'm optimizing the humor...", "Just a moment, I'm tuning the algorithms...", 'Warp speed engaged...', 'Mining for more Dilithium crystals...', "Don't panic...", 'Following the white rabbit...', 'The truth is in here... somewhere...', 'Blowing on the cartridge...', 'Loading... Do a barrel roll!', 'Waiting for the respawn...', 'Finishing the Kessel Run in less than 12 parsecs...', "The cake is not a lie, it's just still loading...", 'Fiddling with the character creation screen...', "Just a moment, I'm finding the right meme...", "Pressing 'A' to continue...", 'Herding digital cats...', 'Polishing the pixels...', 'Finding a suitable loading screen pun...', 'Distracting you with this witty phrase...', 'Almost there... probably...', 'Our hamsters are working as fast as they can...', 'Giving Cloudy a pat on the head...', 'Petting the cat...', 'Rickrolling my boss...', 'Never gonna give you up, never gonna let you down...', 'Slapping the bass...', 'Tasting the snozberries...', "I'm going the distance, I'm going for speed...", 'Is this the real life? Is this just fantasy?...', "I've got a good feeling about this...", 'Poking the bear...', 'Doing research on the latest memes...', 'Figuring out how to make this more witty...', 'Hmmm... let me think...', 'What do you call a fish with no eyes? A fsh...', 'Why did the computer go to therapy? It had too many bytes...', "Why don't programmers like nature? It has too many bugs...", 'Why do programmers prefer dark mode? Because light attracts bugs...', 'Why did the developer go broke? Because they used up all their cache...', "What can you do with a broken pencil? Nothing, it's pointless...", 'Applying percussive maintenance...', 'Searching for the correct USB orientation...', 'Ensuring the magic smoke stays inside the wires...', 'Rewriting in Rust for no particular reason...', 'Trying to exit Vim...', 'Spinning up the hamster wheel...', "That's not a bug, it's an undocumented feature...", 'Engage.', "I'll be back... with an answer.", 'My other process is a TARDIS...', 'Communing with the machine spirit...', 'Letting the thoughts marinate...', 'Just remembered where I put my keys...', 'Pondering the orb...', "I've seen things you people wouldn't believe... like a user who reads loading messages.", 'Initiating thoughtful gaze...', "What's a computer's favorite snack? Microchips.", "Why do Java developers wear glasses? Because they don't C#.", 'Charging the laser... pew pew!', 'Dividing by zero... just kidding!', 'Looking for an adult superviso... I mean, processing.', 'Making it go beep boop.', 'Buffering... because even AIs need a moment.', 'Entangling quantum particles for a faster response...', 'Polishing the chrome... on the algorithms.', 'Are you not entertained? (Working on it!)', 'Summoning the code gremlins... to help, of course.', 'Just waiting for the dial-up tone to finish...', 'Recalibrating the humor-o-meter.', 'My other loading screen is even funnier.', "Pretty sure there's a cat walking on the keyboard somewhere...", 'Enhancing... Enhancing... Still loading.', "It's not a bug, it's a feature... of this loading screen.", 'Have you tried turning it off and on again? (The loading screen, not me.)', 'Constructing additional pylons...', "New line? That's Ctrl+J.", ]; const getRandomLoadingMessage = () => WITTY_LOADING_PHRASES[ Math.floor(Math.random() * WITTY_LOADING_PHRASES.length) ]; type EditMode = 'ask' | 'auto' | 'plan'; export const App: React.FC = () => { const vscode = useVSCode(); const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); const [loadingMessage, setLoadingMessage] = useState(''); const [currentStreamContent, setCurrentStreamContent] = useState(''); const [qwenSessions, setQwenSessions] = useState< Array> >([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [currentSessionTitle, setCurrentSessionTitle] = useState('Past Conversations'); const [showSessionSelector, setShowSessionSelector] = useState(false); const [sessionSearchQuery, setSessionSearchQuery] = useState(''); const [permissionRequest, setPermissionRequest] = useState<{ options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); const [toolCalls, setToolCalls] = useState>( new Map(), ); const [planEntries, setPlanEntries] = useState([]); const messagesEndRef = useRef(null); const inputFieldRef = useRef(null); const [showBanner, setShowBanner] = useState(true); const currentStreamContentRef = useRef(''); const [editMode, setEditMode] = useState('ask'); const [thinkingEnabled, setThinkingEnabled] = useState(false); const [activeFileName, setActiveFileName] = useState(null); const [activeFilePath, setActiveFilePath] = useState(null); const [activeSelection, setActiveSelection] = useState<{ startLine: number; endLine: number; } | null>(null); const [isComposing, setIsComposing] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false); const [savedSessionTags, setSavedSessionTags] = useState([]); // Workspace files cache const [workspaceFiles, setWorkspaceFiles] = useState< Array<{ id: string; label: string; description: string; path: string; }> >([]); // File reference map: @filename -> full path const fileReferenceMap = useRef>(new Map()); // Request workspace files on mount or when @ is first triggered const hasRequestedFilesRef = useRef(false); // Debounce timer for search requests const searchTimerRef = useRef(null); // Get completion items based on trigger character const getCompletionItems = useCallback( async (trigger: '@' | '/', query: string): Promise => { if (trigger === '@') { // Request workspace files on first @ trigger if (!hasRequestedFilesRef.current) { hasRequestedFilesRef.current = true; vscode.postMessage({ type: 'getWorkspaceFiles', data: {}, }); } // Convert workspace files to completion items const fileIcon = ( ); // Convert all files to items const allItems: CompletionItem[] = workspaceFiles.map((file) => ({ id: file.id, label: file.label, description: file.description, type: 'file' as const, icon: fileIcon, value: file.path, })); // If query provided, filter locally AND request from backend (debounced) if (query && query.length >= 1) { // Clear previous search timer if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } // Debounce backend search request (300ms) searchTimerRef.current = setTimeout(() => { vscode.postMessage({ type: 'getWorkspaceFiles', data: { query }, }); }, 300); // Filter locally for immediate feedback const lowerQuery = query.toLowerCase(); const filtered = allItems.filter( (item) => item.label.toLowerCase().includes(lowerQuery) || (item.description && item.description.toLowerCase().includes(lowerQuery)), ); return filtered; } return allItems; } else { // Slash commands - only /login for now const commands: CompletionItem[] = [ { id: 'login', label: '/login', description: 'Login to Qwen Code', type: 'command', icon: ( ), }, ]; return commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()), ); } }, [vscode, workspaceFiles], ); // Use completion trigger hook const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); // Don't auto-refresh completion menu when workspace files update // This was causing flickering. User can re-type to get fresh results. const handlePermissionRequest = React.useCallback( (request: { options: PermissionOption[]; toolCall: PermissionToolCall; }) => { setPermissionRequest(request); }, [], ); const handlePermissionResponse = React.useCallback( (optionId: string) => { vscode.postMessage({ type: 'permissionResponse', data: { optionId }, }); setPermissionRequest(null); }, [vscode], ); // Handle completion item selection const handleCompletionSelect = useCallback( (item: CompletionItem) => { if (!inputFieldRef.current) { return; } const inputElement = inputFieldRef.current; const currentText = inputElement.textContent || ''; if (item.type === 'command') { // Handle /login command directly if (item.label === '/login') { // Clear input field inputElement.textContent = ''; setInputText(''); // Close completion completion.closeCompletion(); // Send login command to extension vscode.postMessage({ type: 'login', data: {}, }); return; } // For other commands, replace entire input with command inputElement.textContent = item.label + ' '; setInputText(item.label + ' '); // Move cursor to end setTimeout(() => { const range = document.createRange(); const sel = window.getSelection(); if (inputElement.firstChild) { range.setStart(inputElement.firstChild, (item.label + ' ').length); range.collapse(true); } else { range.selectNodeContents(inputElement); range.collapse(false); } sel?.removeAllRanges(); sel?.addRange(range); inputElement.focus(); }, 10); } else if (item.type === 'file') { // Store file reference mapping const filePath = (item.value as string) || item.label; fileReferenceMap.current.set(item.label, filePath); console.log('[handleCompletionSelect] Current text:', currentText); console.log('[handleCompletionSelect] Selected file:', item.label); // Find the @ position in current text const atPos = currentText.lastIndexOf('@'); if (atPos !== -1) { // Find the end of the query (could be at cursor or at next space/end) const textAfterAt = currentText.substring(atPos + 1); const spaceIndex = textAfterAt.search(/[\s\n]/); const queryEnd = spaceIndex === -1 ? currentText.length : atPos + 1 + spaceIndex; // Replace from @ to end of query with @filename const textBefore = currentText.substring(0, atPos); const textAfter = currentText.substring(queryEnd); const newText = `${textBefore}@${item.label} ${textAfter}`; console.log('[handleCompletionSelect] New text:', newText); // Update the input inputElement.textContent = newText; setInputText(newText); // Set cursor after the inserted filename (after the space) const newCursorPos = atPos + item.label.length + 2; // +1 for @, +1 for space // Wait for DOM to update, then set cursor setTimeout(() => { const textNode = inputElement.firstChild; if (textNode && textNode.nodeType === Node.TEXT_NODE) { const selection = window.getSelection(); if (selection) { const range = document.createRange(); try { range.setStart( textNode, Math.min(newCursorPos, newText.length), ); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } catch (e) { console.error( '[handleCompletionSelect] Error setting cursor:', e, ); // Fallback: move cursor to end range.selectNodeContents(inputElement); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } } } inputElement.focus(); }, 10); } } // Close completion completion.closeCompletion(); }, [completion, vscode], ); // Handle attach context button click (Cmd/Ctrl + /) const handleAttachContextClick = useCallback(async () => { if (inputFieldRef.current) { // Focus the input first inputFieldRef.current.focus(); // Insert @ at the end of current text const currentText = inputFieldRef.current.textContent || ''; const newText = currentText ? `${currentText} @` : '@'; inputFieldRef.current.textContent = newText; setInputText(newText); // Move cursor to end const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(inputFieldRef.current); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); // Wait for DOM to update before getting position and opening menu requestAnimationFrame(async () => { if (!inputFieldRef.current) { return; } // Get cursor position for menu placement let position = { top: 0, left: 0 }; const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { try { const currentRange = selection.getRangeAt(0); const rangeRect = currentRange.getBoundingClientRect(); if (rangeRect.top > 0 && rangeRect.left > 0) { position = { top: rangeRect.top, left: rangeRect.left, }; } else { const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } } catch (error) { console.error('[App] Error getting cursor position:', error); const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } } else { const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } // Open completion menu with @ trigger await completion.openCompletion('@', '', position); }); } }, [completion]); // Handle keyboard shortcut for attach context (Cmd/Ctrl + /) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Cmd/Ctrl + / for attach context if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); handleAttachContextClick(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleAttachContextClick]); // Handle removing context attachment const handleToolCallUpdate = React.useCallback( (update: ToolCallUpdate) => { setToolCalls((prevToolCalls) => { const newMap = new Map(prevToolCalls); const existing = newMap.get(update.toolCallId); // Helper function to safely convert title to string const safeTitle = (title: unknown): string => { if (typeof title === 'string') { return title; } if (title && typeof title === 'object') { return JSON.stringify(title); } return 'Tool Call'; }; 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: safeTitle(update.title), status: update.status || 'pending', rawInput: update.rawInput as string | object | undefined, content, locations: update.locations, }); } else if (update.type === 'tool_call_update') { // Update existing tool call, or create if it doesn't exist 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; if (existing) { // Update existing tool call newMap.set(update.toolCallId, { ...existing, ...(update.kind && { kind: update.kind }), ...(update.title && { title: safeTitle(update.title) }), ...(update.status && { status: update.status }), ...(updatedContent && { content: updatedContent }), ...(update.locations && { locations: update.locations }), }); } else { // Create new tool call if it doesn't exist (missed the initial tool_call message) newMap.set(update.toolCallId, { toolCallId: update.toolCallId, kind: update.kind || 'other', title: safeTitle(update.title), status: update.status || 'pending', rawInput: update.rawInput as string | object | undefined, content: updatedContent, locations: update.locations, }); } } return newMap; }); }, [setToolCalls], ); const handleSaveSession = useCallback( (tag: string) => { // Send save session request to extension vscode.postMessage({ type: 'saveSession', data: { tag }, }); setShowSaveDialog(false); }, [vscode], ); // Handle save session response const handleSaveSessionResponse = useCallback( (response: { success: boolean; message?: string }) => { if (response.success) { // Add the new tag to saved session tags if (response.message) { const tagMatch = response.message.match(/tag: (.+)$/); if (tagMatch) { setSavedSessionTags((prev) => [...prev, tagMatch[1]]); } } } else { // Handle error - could show a toast or error message console.error('Failed to save session:', response.message); } }, [setSavedSessionTags], ); useEffect(() => { // Listen for messages from extension const handleMessage = (event: MessageEvent) => { const message = event.data; // console.log('[App] Received message from extension:', message.type, message); 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(''); currentStreamContentRef.current = ''; break; case 'streamChunk': { const chunkData = message.data; if (chunkData.role === 'thinking') { // Handle thinking chunks separately if needed setCurrentStreamContent((prev) => { const newContent = prev + chunkData.chunk; currentStreamContentRef.current = newContent; return newContent; }); } else { setCurrentStreamContent((prev) => { const newContent = prev + chunkData.chunk; currentStreamContentRef.current = newContent; return newContent; }); } break; } case 'thoughtChunk': { const chunkData = message.data; console.log('[App] 🧠 THOUGHT CHUNK RECEIVED:', chunkData); // Handle thought chunks for AI thinking display const thinkingMessage: TextMessage = { role: 'thinking', content: chunkData.content || chunkData.chunk || '', timestamp: Date.now(), }; console.log('[App] 🧠 Adding thinking message:', thinkingMessage); setMessages((prev) => [...prev, thinkingMessage]); break; } case 'streamEnd': // Finalize the streamed message if (currentStreamContentRef.current) { const assistantMessage: TextMessage = { role: 'assistant', content: currentStreamContentRef.current, timestamp: Date.now(), }; setMessages((prev) => [...prev, assistantMessage]); } setIsStreaming(false); setIsWaitingForResponse(false); // Clear waiting state setCurrentStreamContent(''); currentStreamContentRef.current = ''; break; case 'error': setIsStreaming(false); setIsWaitingForResponse(false); break; // case 'notLoggedIn': // // Show not logged in message with login button // console.log('[App] Received notLoggedIn message:', message.data); // setIsStreaming(false); // setIsWaitingForResponse(false); // setNotLoggedInMessage( // (message.data as { message: string })?.message || // 'Please login to start chatting.', // ); // console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message); // break; case 'permissionRequest': { // Show permission dialog handlePermissionRequest(message.data); // Also create a tool call entry for the permission request // This ensures that if it's rejected, we can show it properly const permToolCall = message.data?.toolCall as { toolCallId?: string; kind?: string; title?: string; status?: string; content?: unknown[]; locations?: Array<{ path: string; line?: number | null }>; }; if (permToolCall?.toolCallId) { // Infer kind from title if not provided let kind = permToolCall.kind || 'execute'; if (permToolCall.title) { const title = permToolCall.title.toLowerCase(); if (title.includes('touch') || title.includes('echo')) { kind = 'execute'; } else if (title.includes('read') || title.includes('cat')) { kind = 'read'; } else if (title.includes('write') || title.includes('edit')) { kind = 'edit'; } } handleToolCallUpdate({ type: 'tool_call', toolCallId: permToolCall.toolCallId, kind, title: permToolCall.title, status: permToolCall.status || 'pending', content: permToolCall.content as Array<{ type: 'content' | 'diff'; content?: { type: string; text?: string; [key: string]: unknown; }; path?: string; oldText?: string | null; newText?: string; [key: string]: unknown; }>, locations: permToolCall.locations, }); } break; } case 'plan': // Update plan entries console.log('[App] Plan received:', message.data); if (message.data.entries && Array.isArray(message.data.entries)) { setPlanEntries(message.data.entries as PlanEntry[]); } break; case 'toolCall': case 'toolCallUpdate': // Handle tool call updates handleToolCallUpdate(message.data); break; case 'qwenSessionList': { const sessions = message.data.sessions || []; setQwenSessions(sessions); // Only update title if we have a current session selected if (currentSessionId && sessions.length > 0) { // Update title for the current session if it exists in the list const currentSession = sessions.find( (s: Record) => (s.id as string) === currentSessionId || (s.sessionId as string) === currentSessionId, ); if (currentSession) { const title = (currentSession.title as string) || (currentSession.name as string) || 'Past Conversations'; setCurrentSessionTitle(title); } } break; } case 'qwenSessionSwitched': console.log('[App] Session switched:', message.data); setShowSessionSelector(false); // Update current session ID if (message.data.sessionId) { setCurrentSessionId(message.data.sessionId as string); console.log( '[App] Current session ID updated to:', message.data.sessionId, ); } // Update current session title from session object if (message.data.session) { const session = message.data.session as Record; const title = (session.title as string) || (session.name as string) || 'Past Conversations'; setCurrentSessionTitle(title); console.log('[App] Session title updated to:', title); } // Load messages from the session if (message.data.messages) { console.log( '[App] Loading messages:', message.data.messages.length, ); setMessages(message.data.messages); } else { console.log('[App] No messages in session, clearing'); setMessages([]); } setCurrentStreamContent(''); setToolCalls(new Map()); setPlanEntries([]); // Clear plan entries when switching sessions break; case 'conversationCleared': setMessages([]); setCurrentStreamContent(''); setToolCalls(new Map()); // Reset session ID and title when conversation is cleared (new session created) setCurrentSessionId(null); setCurrentSessionTitle('Past Conversations'); break; case 'sessionTitleUpdated': { // Update session title when first message is sent const sessionId = message.data?.sessionId as string; const title = message.data?.title as string; if (sessionId && title) { console.log('[App] Session title updated:', title); setCurrentSessionId(sessionId); setCurrentSessionTitle(title); } break; } case 'activeEditorChanged': { // 从扩展接收当前激活编辑器的文件名和选中的行号 const fileName = message.data?.fileName as string | null; const filePath = message.data?.filePath as string | null; const selection = message.data?.selection as { startLine: number; endLine: number; } | null; setActiveFileName(fileName); setActiveFilePath(filePath); setActiveSelection(selection); break; } case 'fileAttached': { // Handle file attachment from VSCode - insert as @mention const attachment = message.data as { id: string; type: string; name: string; value: string; }; // Store file reference fileReferenceMap.current.set(attachment.name, attachment.value); // Insert @filename into input if (inputFieldRef.current) { const currentText = inputFieldRef.current.textContent || ''; const newText = currentText ? `${currentText} @${attachment.name} ` : `@${attachment.name} `; inputFieldRef.current.textContent = newText; setInputText(newText); // Move cursor to end const range = document.createRange(); const sel = window.getSelection(); range.selectNodeContents(inputFieldRef.current); range.collapse(false); sel?.removeAllRanges(); sel?.addRange(range); } break; } case 'workspaceFiles': { // Handle workspace files list from VSCode const files = message.data?.files as Array<{ id: string; label: string; description: string; path: string; }>; if (files) { setWorkspaceFiles(files); } break; } case 'saveSessionResponse': { // Handle save session response handleSaveSessionResponse(message.data); break; } default: break; } }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [ currentSessionId, handlePermissionRequest, handleToolCallUpdate, handleSaveSessionResponse, ]); 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]); // Request current active editor on component mount useEffect(() => { vscode.postMessage({ type: 'getActiveEditor', data: {} }); }, [vscode]); // Toggle edit mode: ask → auto → plan → ask const handleToggleEditMode = () => { setEditMode((prev) => { if (prev === 'ask') { return 'auto'; } if (prev === 'auto') { return 'plan'; } return 'ask'; }); }; // Toggle thinking on/off const handleToggleThinking = () => { setThinkingEnabled((prev) => !prev); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!inputText.trim() || isStreaming) { return; } // Check if this is a /login command if (inputText.trim() === '/login') { // Clear input field setInputText(''); if (inputFieldRef.current) { inputFieldRef.current.textContent = ''; } // Send login command to extension vscode.postMessage({ type: 'login', data: {}, }); return; } // Set waiting state with random loading message setIsWaitingForResponse(true); setLoadingMessage(getRandomLoadingMessage()); // Parse @file references from input text const context: Array<{ type: string; name: string; value: string; startLine?: number; endLine?: number; }> = []; const fileRefPattern = /@([^\s]+)/g; let match; while ((match = fileRefPattern.exec(inputText)) !== null) { const fileName = match[1]; const filePath = fileReferenceMap.current.get(fileName); if (filePath) { context.push({ type: 'file', name: fileName, value: filePath, }); } } // Add active file selection context if present if (activeFilePath) { const fileName = activeFileName || 'current file'; context.push({ type: 'file', name: fileName, value: activeFilePath, startLine: activeSelection?.startLine, endLine: activeSelection?.endLine, }); } // Build file context for the message let fileContextForMessage: | { fileName: string; filePath: string; startLine?: number; endLine?: number; } | undefined; if (activeFilePath && activeFileName) { fileContextForMessage = { fileName: activeFileName, filePath: activeFilePath, startLine: activeSelection?.startLine, endLine: activeSelection?.endLine, }; } vscode.postMessage({ type: 'sendMessage', data: { text: inputText, context: context.length > 0 ? context : undefined, fileContext: fileContextForMessage, }, }); // Clear input field and file reference map setInputText(''); if (inputFieldRef.current) { inputFieldRef.current.textContent = ''; } fileReferenceMap.current.clear(); }; const handleLoadQwenSessions = () => { vscode.postMessage({ type: 'getQwenSessions', data: {} }); setShowSessionSelector(true); }; const handleNewQwenSession = () => { // Send message to open a new chat tab vscode.postMessage({ type: 'openNewChatTab', data: {} }); setShowSessionSelector(false); }; // Time ago formatter (matching Claude Code) const getTimeAgo = (timestamp: string): string => { if (!timestamp) { return ''; } const now = new Date().getTime(); const then = new Date(timestamp).getTime(); const diffMs = now - then; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) { return 'now'; } if (diffMins < 60) { return `${diffMins}m`; } if (diffHours < 24) { return `${diffHours}h`; } if (diffDays === 1) { return 'Yesterday'; } if (diffDays < 7) { return `${diffDays}d`; } return new Date(timestamp).toLocaleDateString(); }; // Group sessions by date (matching Claude Code) const groupSessionsByDate = ( sessions: Array>, ): Array<{ label: string; sessions: Array> }> => { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const groups: { [key: string]: Array>; } = { Today: [], Yesterday: [], 'This Week': [], Older: [], }; sessions.forEach((session) => { const timestamp = (session.lastUpdated as string) || (session.startTime as string) || ''; if (!timestamp) { groups['Older'].push(session); return; } const sessionDate = new Date(timestamp); const sessionDay = new Date( sessionDate.getFullYear(), sessionDate.getMonth(), sessionDate.getDate(), ); if (sessionDay.getTime() === today.getTime()) { groups['Today'].push(session); } else if (sessionDay.getTime() === yesterday.getTime()) { groups['Yesterday'].push(session); } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { groups['This Week'].push(session); } else { groups['Older'].push(session); } }); return Object.entries(groups) .filter(([, sessions]) => sessions.length > 0) .map(([label, sessions]) => ({ label, sessions })); }; // Filter sessions by search query const filteredSessions = React.useMemo(() => { if (!sessionSearchQuery.trim()) { return qwenSessions; } const query = sessionSearchQuery.toLowerCase(); return qwenSessions.filter((session) => { const title = ( (session.title as string) || (session.name as string) || '' ).toLowerCase(); return title.includes(query); }); }, [qwenSessions, sessionSearchQuery]); const handleSwitchSession = (sessionId: string) => { if (sessionId === currentSessionId) { console.log('[App] Already on this session, ignoring'); setShowSessionSelector(false); return; } console.log('[App] Switching to session:', sessionId); vscode.postMessage({ type: 'switchQwenSession', data: { sessionId }, }); // Don't set currentSessionId or close selector here - wait for qwenSessionSwitched response }; // Check if there are any messages or active content const hasContent = messages.length > 0 || isStreaming || toolCalls.size > 0 || planEntries.length > 0; return (
{showSessionSelector && ( <>
setShowSessionSelector(false)} />
e.stopPropagation()} > {/* Search Box */}
setSessionSearchQuery(e.target.value)} />
{/* Session List with Grouping */}
{filteredSessions.length === 0 ? (
{sessionSearchQuery ? 'No matching sessions' : 'No sessions available'}
) : ( groupSessionsByDate(filteredSessions).map((group) => (
{group.label}
{group.sessions.map((session) => { const sessionId = (session.id as string) || (session.sessionId as string) || ''; const title = (session.title as string) || (session.name as string) || 'Untitled'; const lastUpdated = (session.lastUpdated as string) || (session.startTime as string) || ''; const isActive = sessionId === currentSessionId; return ( ); })}
)) )}
)} setShowSaveDialog(true)} onNewSession={handleNewQwenSession} />
{!hasContent ? ( ) : ( <> {messages.map((msg, index) => { const handleFileClick = (path: string) => { vscode.postMessage({ type: 'openFile', data: { path }, }); }; if (msg.role === 'thinking') { return ( ); } if (msg.role === 'user') { return ( ); } return ( ); })} {/* In-Progress Tool Calls - show only pending/in_progress */} {Array.from(toolCalls.values()) .filter( (toolCall) => toolCall.status === 'pending' || toolCall.status === 'in_progress', ) .map((toolCall) => ( ))} {/* Completed Tool Calls - only show those with actual output */} {Array.from(toolCalls.values()) .filter( (toolCall) => (toolCall.status === 'completed' || toolCall.status === 'failed') && hasToolCallOutput(toolCall), ) .map((toolCall) => ( ))} {/* Plan Display - shows task list when available */} {planEntries.length > 0 && } {/* Loading/Waiting Message - in message list */} {isWaitingForResponse && loadingMessage && ( )} {/* Not Logged In Message with Login Button - COMMENTED OUT */} {/* {notLoggedInMessage && ( <> {console.log('[App] Rendering NotLoggedInMessage with message:', notLoggedInMessage)} { setNotLoggedInMessage(null); vscode.postMessage({ type: 'login', data: {}, }); }} onDismiss={() => setNotLoggedInMessage(null)} /> )} */} {isStreaming && currentStreamContent && ( { vscode.postMessage({ type: 'openFile', data: { path }, }); }} /> )}
)}
{/* Info Banner */} setShowBanner(false)} onLinkClick={(e) => { e.preventDefault(); vscode.postMessage({ type: 'openSettings', data: {} }); }} /> setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} onKeyDown={() => {}} onSubmit={handleSubmit} onToggleEditMode={handleToggleEditMode} onToggleThinking={handleToggleThinking} onFocusActiveEditor={() => { vscode.postMessage({ type: 'focusActiveEditor', data: {}, }); }} onShowCommandMenu={async () => { if (inputFieldRef.current) { inputFieldRef.current.focus(); const selection = window.getSelection(); let position = { top: 0, left: 0 }; if (selection && selection.rangeCount > 0) { try { const range = selection.getRangeAt(0); const rangeRect = range.getBoundingClientRect(); if (rangeRect.top > 0 && rangeRect.left > 0) { position = { top: rangeRect.top, left: rangeRect.left, }; } else { const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } } catch (error) { console.error('[App] Error getting cursor position:', error); const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } } else { const inputRect = inputFieldRef.current.getBoundingClientRect(); position = { top: inputRect.top, left: inputRect.left }; } await completion.openCompletion('/', '', position); } }} onAttachContext={handleAttachContextClick} completionIsOpen={completion.isOpen} /> {/* Save Session Dialog */} setShowSaveDialog(false)} onSave={handleSaveSession} existingTags={savedSessionTags} /> {/* Permission Drawer - Cursor style */} {permissionRequest && ( setPermissionRequest(null)} /> )} {/* Completion Menu for @ and / */} {completion.isOpen && completion.items.length > 0 && ( )}
); };