mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 17:27:54 +00:00
feat(vscode-ide-companion): split module & notes in english
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { VSCodeAPI } from '../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* File context management Hook
|
||||
* Manages active file, selection content, and workspace file list
|
||||
*/
|
||||
export const useFileContext = (vscode: VSCodeAPI) => {
|
||||
const [activeFileName, setActiveFileName] = useState<string | null>(null);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
const [activeSelection, setActiveSelection] = useState<{
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
} | null>(null);
|
||||
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<
|
||||
Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
// File reference mapping: @filename -> full path
|
||||
const fileReferenceMap = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Whether workspace files have been requested
|
||||
const hasRequestedFilesRef = useRef(false);
|
||||
|
||||
// Search debounce timer
|
||||
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Request workspace files
|
||||
*/
|
||||
const requestWorkspaceFiles = useCallback(
|
||||
(query?: string) => {
|
||||
if (!hasRequestedFilesRef.current && !query) {
|
||||
hasRequestedFilesRef.current = true;
|
||||
}
|
||||
|
||||
// If there's a query, clear previous timer and set up debounce
|
||||
if (query && query.length >= 1) {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: { query },
|
||||
});
|
||||
}, 300);
|
||||
} else {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: query ? { query } : {},
|
||||
});
|
||||
}
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Add file reference
|
||||
*/
|
||||
const addFileReference = useCallback((fileName: string, filePath: string) => {
|
||||
fileReferenceMap.current.set(fileName, filePath);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get file reference
|
||||
*/
|
||||
const getFileReference = useCallback(
|
||||
(fileName: string) => fileReferenceMap.current.get(fileName),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear file references
|
||||
*/
|
||||
const clearFileReferences = useCallback(() => {
|
||||
fileReferenceMap.current.clear();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Request active editor info
|
||||
*/
|
||||
const requestActiveEditor = useCallback(() => {
|
||||
vscode.postMessage({ type: 'getActiveEditor', data: {} });
|
||||
}, [vscode]);
|
||||
|
||||
/**
|
||||
* Focus on active editor
|
||||
*/
|
||||
const focusActiveEditor = useCallback(() => {
|
||||
vscode.postMessage({
|
||||
type: 'focusActiveEditor',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
return {
|
||||
// State
|
||||
activeFileName,
|
||||
activeFilePath,
|
||||
activeSelection,
|
||||
workspaceFiles,
|
||||
hasRequestedFiles: hasRequestedFilesRef.current,
|
||||
|
||||
// State setters
|
||||
setActiveFileName,
|
||||
setActiveFilePath,
|
||||
setActiveSelection,
|
||||
setWorkspaceFiles,
|
||||
|
||||
// File reference operations
|
||||
addFileReference,
|
||||
getFileReference,
|
||||
clearFileReferences,
|
||||
|
||||
// Operations
|
||||
requestWorkspaceFiles,
|
||||
requestActiveEditor,
|
||||
focusActiveEditor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export interface TextMessage {
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
fileContext?: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Message handling Hook
|
||||
* Manages message list, streaming responses, and loading state
|
||||
*/
|
||||
export const useMessageHandling = () => {
|
||||
const [messages, setMessages] = useState<TextMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
const [currentStreamContent, setCurrentStreamContent] = useState('');
|
||||
|
||||
// Use ref to store current stream content, avoiding useEffect dependency issues
|
||||
const currentStreamContentRef = useRef<string>('');
|
||||
|
||||
/**
|
||||
* Add message
|
||||
*/
|
||||
const addMessage = useCallback((message: TextMessage) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear messages
|
||||
*/
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start streaming response
|
||||
*/
|
||||
const startStreaming = useCallback(() => {
|
||||
setIsStreaming(true);
|
||||
setCurrentStreamContent('');
|
||||
currentStreamContentRef.current = '';
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add stream chunk
|
||||
*/
|
||||
const appendStreamChunk = useCallback((chunk: string) => {
|
||||
setCurrentStreamContent((prev) => {
|
||||
const newContent = prev + chunk;
|
||||
currentStreamContentRef.current = newContent;
|
||||
return newContent;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* End streaming response
|
||||
*/
|
||||
const endStreaming = useCallback(() => {
|
||||
// If there is streaming content, add it as complete assistant message
|
||||
if (currentStreamContentRef.current) {
|
||||
const assistantMessage: TextMessage = {
|
||||
role: 'assistant',
|
||||
content: currentStreamContentRef.current,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
}
|
||||
|
||||
setIsStreaming(false);
|
||||
setIsWaitingForResponse(false);
|
||||
setCurrentStreamContent('');
|
||||
currentStreamContentRef.current = '';
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set waiting for response state
|
||||
*/
|
||||
const setWaitingForResponse = useCallback((message: string) => {
|
||||
setIsWaitingForResponse(true);
|
||||
setLoadingMessage(message);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear waiting for response state
|
||||
*/
|
||||
const clearWaitingForResponse = useCallback(() => {
|
||||
setIsWaitingForResponse(false);
|
||||
setLoadingMessage('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
messages,
|
||||
isStreaming,
|
||||
isWaitingForResponse,
|
||||
loadingMessage,
|
||||
currentStreamContent,
|
||||
|
||||
// Operations
|
||||
addMessage,
|
||||
clearMessages,
|
||||
startStreaming,
|
||||
appendStreamChunk,
|
||||
endStreaming,
|
||||
setWaitingForResponse,
|
||||
clearWaitingForResponse,
|
||||
setMessages,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { VSCodeAPI } from '../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* Session management Hook
|
||||
* Manages session list, current session, session switching, and search
|
||||
*/
|
||||
export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||
const [qwenSessions, setQwenSessions] = useState<
|
||||
Array<Record<string, unknown>>
|
||||
>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [currentSessionTitle, setCurrentSessionTitle] =
|
||||
useState<string>('Past Conversations');
|
||||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
||||
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Filter session list
|
||||
*/
|
||||
const filteredSessions = 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]);
|
||||
|
||||
/**
|
||||
* Load session list
|
||||
*/
|
||||
const handleLoadQwenSessions = useCallback(() => {
|
||||
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
||||
setShowSessionSelector(true);
|
||||
}, [vscode]);
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*/
|
||||
const handleNewQwenSession = useCallback(() => {
|
||||
vscode.postMessage({ type: 'openNewChatTab', data: {} });
|
||||
setShowSessionSelector(false);
|
||||
}, [vscode]);
|
||||
|
||||
/**
|
||||
* Switch session
|
||||
*/
|
||||
const handleSwitchSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (sessionId === currentSessionId) {
|
||||
console.log('[useSessionManagement] Already on this session, ignoring');
|
||||
setShowSessionSelector(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useSessionManagement] Switching to session:', sessionId);
|
||||
vscode.postMessage({
|
||||
type: 'switchQwenSession',
|
||||
data: { sessionId },
|
||||
});
|
||||
},
|
||||
[currentSessionId, vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Save session
|
||||
*/
|
||||
const handleSaveSession = useCallback(
|
||||
(tag: string) => {
|
||||
vscode.postMessage({
|
||||
type: 'saveSession',
|
||||
data: { tag },
|
||||
});
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理Save session响应
|
||||
*/
|
||||
const handleSaveSessionResponse = useCallback(
|
||||
(response: { success: boolean; message?: string }) => {
|
||||
if (response.success) {
|
||||
if (response.message) {
|
||||
const tagMatch = response.message.match(/tag: (.+)$/);
|
||||
if (tagMatch) {
|
||||
setSavedSessionTags((prev) => [...prev, tagMatch[1]]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save session:', response.message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
qwenSessions,
|
||||
currentSessionId,
|
||||
currentSessionTitle,
|
||||
showSessionSelector,
|
||||
sessionSearchQuery,
|
||||
filteredSessions,
|
||||
savedSessionTags,
|
||||
|
||||
// State setters
|
||||
setQwenSessions,
|
||||
setCurrentSessionId,
|
||||
setCurrentSessionTitle,
|
||||
setShowSessionSelector,
|
||||
setSessionSearchQuery,
|
||||
setSavedSessionTags,
|
||||
|
||||
// Operations
|
||||
handleLoadQwenSessions,
|
||||
handleNewQwenSession,
|
||||
handleSwitchSession,
|
||||
handleSaveSession,
|
||||
handleSaveSessionResponse,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { VSCodeAPI } from './useVSCode.js';
|
||||
import { getRandomLoadingMessage } from '../constants/loadingMessages.js';
|
||||
|
||||
interface UseMessageSubmitProps {
|
||||
vscode: VSCodeAPI;
|
||||
inputText: string;
|
||||
setInputText: (text: string) => void;
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
isStreaming: boolean;
|
||||
|
||||
fileContext: {
|
||||
getFileReference: (fileName: string) => string | undefined;
|
||||
activeFilePath: string | null;
|
||||
activeFileName: string | null;
|
||||
activeSelection: { startLine: number; endLine: number } | null;
|
||||
clearFileReferences: () => void;
|
||||
};
|
||||
|
||||
messageHandling: {
|
||||
setWaitingForResponse: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Message submit Hook
|
||||
* Handles message submission logic and context parsing
|
||||
*/
|
||||
export const useMessageSubmit = ({
|
||||
vscode,
|
||||
inputText,
|
||||
setInputText,
|
||||
inputFieldRef,
|
||||
isStreaming,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
}: UseMessageSubmitProps) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!inputText.trim() || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle /login command
|
||||
if (inputText.trim() === '/login') {
|
||||
setInputText('');
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.textContent = '';
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: 'login',
|
||||
data: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
messageHandling.setWaitingForResponse(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 = fileContext.getFileReference(fileName);
|
||||
|
||||
if (filePath) {
|
||||
context.push({
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add active file selection context if present
|
||||
if (fileContext.activeFilePath) {
|
||||
const fileName = fileContext.activeFileName || 'current file';
|
||||
context.push({
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: fileContext.activeFilePath,
|
||||
startLine: fileContext.activeSelection?.startLine,
|
||||
endLine: fileContext.activeSelection?.endLine,
|
||||
});
|
||||
}
|
||||
|
||||
let fileContextForMessage:
|
||||
| {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (fileContext.activeFilePath && fileContext.activeFileName) {
|
||||
fileContextForMessage = {
|
||||
fileName: fileContext.activeFileName,
|
||||
filePath: fileContext.activeFilePath,
|
||||
startLine: fileContext.activeSelection?.startLine,
|
||||
endLine: fileContext.activeSelection?.endLine,
|
||||
};
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'sendMessage',
|
||||
data: {
|
||||
text: inputText,
|
||||
context: context.length > 0 ? context : undefined,
|
||||
fileContext: fileContextForMessage,
|
||||
},
|
||||
});
|
||||
|
||||
setInputText('');
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.textContent = '';
|
||||
}
|
||||
fileContext.clearFileReferences();
|
||||
},
|
||||
[
|
||||
inputText,
|
||||
isStreaming,
|
||||
setInputText,
|
||||
inputFieldRef,
|
||||
vscode,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleSubmit };
|
||||
};
|
||||
127
packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts
Normal file
127
packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ToolCallData } from '../components/ToolCall.js';
|
||||
import type { ToolCallUpdate } from '../types/toolCall.js';
|
||||
|
||||
/**
|
||||
* Tool call management Hook
|
||||
* Manages tool call states and updates
|
||||
*/
|
||||
export const useToolCalls = () => {
|
||||
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle tool call update
|
||||
*/
|
||||
const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => {
|
||||
setToolCalls((prevToolCalls) => {
|
||||
const newMap = new Map(prevToolCalls);
|
||||
const existing = newMap.get(update.toolCallId);
|
||||
|
||||
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') {
|
||||
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') {
|
||||
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) {
|
||||
const mergedContent = updatedContent
|
||||
? [...(existing.content || []), ...updatedContent]
|
||||
: existing.content;
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
...existing,
|
||||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
content: mergedContent,
|
||||
...(update.locations && { locations: update.locations }),
|
||||
});
|
||||
} else {
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: update.title ? safeTitle(update.title) : '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content: updatedContent,
|
||||
locations: update.locations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear all tool calls
|
||||
*/
|
||||
const clearToolCalls = useCallback(() => {
|
||||
setToolCalls(new Map());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get in-progress tool calls
|
||||
*/
|
||||
const inProgressToolCalls = Array.from(toolCalls.values()).filter(
|
||||
(toolCall) =>
|
||||
toolCall.status === 'pending' || toolCall.status === 'in_progress',
|
||||
);
|
||||
|
||||
/**
|
||||
* Get completed tool calls
|
||||
*/
|
||||
const completedToolCalls = Array.from(toolCalls.values()).filter(
|
||||
(toolCall) =>
|
||||
toolCall.status === 'completed' || toolCall.status === 'failed',
|
||||
);
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
};
|
||||
};
|
||||
@@ -13,14 +13,14 @@ export interface VSCodeAPI {
|
||||
declare const acquireVsCodeApi: () => VSCodeAPI;
|
||||
|
||||
/**
|
||||
* 模块级别的 VS Code API 实例缓存
|
||||
* acquireVsCodeApi() 只能调用一次,必须在模块级别缓存
|
||||
* Module-level VS Code API instance cache
|
||||
* acquireVsCodeApi() can only be called once, must be cached at module level
|
||||
*/
|
||||
let vscodeApiInstance: VSCodeAPI | null = null;
|
||||
|
||||
/**
|
||||
* 获取 VS Code API 实例
|
||||
* 使用模块级别缓存确保 acquireVsCodeApi() 只被调用一次
|
||||
* Get VS Code API instance
|
||||
* Uses module-level cache to ensure acquireVsCodeApi() is only called once
|
||||
*/
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (vscodeApiInstance) {
|
||||
@@ -47,7 +47,7 @@ function getVSCodeAPI(): VSCodeAPI {
|
||||
|
||||
/**
|
||||
* Hook to get VS Code API
|
||||
* 多个组件可以安全地调用此 hook,API 实例会被复用
|
||||
* Multiple components can safely call this hook, API instance will be reused
|
||||
*/
|
||||
export function useVSCode(): VSCodeAPI {
|
||||
return getVSCodeAPI();
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import type { Conversation } from '../../storage/conversationStore.js';
|
||||
import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from '../components/PermissionRequest.js';
|
||||
import type { PlanEntry } from '../components/PlanDisplay.js';
|
||||
import type { ToolCallUpdate } from '../types/toolCall.js';
|
||||
|
||||
interface UseWebViewMessagesProps {
|
||||
// Session management
|
||||
sessionManagement: {
|
||||
currentSessionId: string | null;
|
||||
setQwenSessions: (sessions: Array<Record<string, unknown>>) => void;
|
||||
setCurrentSessionId: (id: string | null) => void;
|
||||
setCurrentSessionTitle: (title: string) => void;
|
||||
setShowSessionSelector: (show: boolean) => void;
|
||||
handleSaveSessionResponse: (response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// File context
|
||||
fileContext: {
|
||||
setActiveFileName: (name: string | null) => void;
|
||||
setActiveFilePath: (path: string | null) => void;
|
||||
setActiveSelection: (
|
||||
selection: { startLine: number; endLine: number } | null,
|
||||
) => void;
|
||||
setWorkspaceFiles: (
|
||||
files: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}>,
|
||||
) => void;
|
||||
addFileReference: (name: string, path: string) => void;
|
||||
};
|
||||
|
||||
// Message handling
|
||||
messageHandling: {
|
||||
setMessages: (
|
||||
messages: Array<{
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
fileContext?: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
}>,
|
||||
) => void;
|
||||
addMessage: (message: {
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}) => void;
|
||||
clearMessages: () => void;
|
||||
startStreaming: () => void;
|
||||
appendStreamChunk: (chunk: string) => void;
|
||||
endStreaming: () => void;
|
||||
clearWaitingForResponse: () => void;
|
||||
};
|
||||
|
||||
// Tool calls
|
||||
handleToolCallUpdate: (update: ToolCallUpdate) => void;
|
||||
clearToolCalls: () => void;
|
||||
|
||||
// Plan
|
||||
setPlanEntries: (entries: PlanEntry[]) => void;
|
||||
|
||||
// Permission
|
||||
handlePermissionRequest: (request: {
|
||||
options: PermissionOption[];
|
||||
toolCall: PermissionToolCall;
|
||||
}) => void;
|
||||
|
||||
// Input
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
setInputText: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView message handling Hook
|
||||
* Handles all messages from VSCode Extension uniformly
|
||||
*/
|
||||
export const useWebViewMessages = ({
|
||||
sessionManagement,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
}: UseWebViewMessagesProps) => {
|
||||
// Use ref to store callbacks to avoid useEffect dependency issues
|
||||
const handlersRef = useRef({
|
||||
sessionManagement,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
});
|
||||
|
||||
// Update refs
|
||||
useEffect(() => {
|
||||
handlersRef.current = {
|
||||
sessionManagement,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
setPlanEntries,
|
||||
handlePermissionRequest,
|
||||
};
|
||||
});
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
const handlers = handlersRef.current;
|
||||
|
||||
switch (message.type) {
|
||||
case 'conversationLoaded': {
|
||||
const conversation = message.data as Conversation;
|
||||
handlers.messageHandling.setMessages(conversation.messages);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message': {
|
||||
handlers.messageHandling.addMessage(message.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'streamStart':
|
||||
handlers.messageHandling.startStreaming();
|
||||
break;
|
||||
|
||||
case 'streamChunk': {
|
||||
handlers.messageHandling.appendStreamChunk(message.data.chunk);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thoughtChunk': {
|
||||
const thinkingMessage = {
|
||||
role: 'thinking' as const,
|
||||
content: message.data.content || message.data.chunk || '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
handlers.messageHandling.addMessage(thinkingMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'streamEnd':
|
||||
handlers.messageHandling.endStreaming();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
break;
|
||||
|
||||
case 'permissionRequest': {
|
||||
handlers.handlePermissionRequest(message.data);
|
||||
|
||||
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) {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStatus = (
|
||||
permToolCall.status === 'pending' ||
|
||||
permToolCall.status === 'in_progress' ||
|
||||
permToolCall.status === 'completed' ||
|
||||
permToolCall.status === 'failed'
|
||||
? permToolCall.status
|
||||
: 'pending'
|
||||
) as ToolCallUpdate['status'];
|
||||
|
||||
handlers.handleToolCallUpdate({
|
||||
type: 'tool_call',
|
||||
toolCallId: permToolCall.toolCallId,
|
||||
kind,
|
||||
title: permToolCall.title,
|
||||
status: normalizedStatus,
|
||||
content: permToolCall.content as ToolCallUpdate['content'],
|
||||
locations: permToolCall.locations,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plan':
|
||||
if (message.data.entries && Array.isArray(message.data.entries)) {
|
||||
handlers.setPlanEntries(message.data.entries as PlanEntry[]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'toolCall':
|
||||
case 'toolCallUpdate': {
|
||||
const toolCallData = message.data;
|
||||
if (toolCallData.sessionUpdate && !toolCallData.type) {
|
||||
toolCallData.type = toolCallData.sessionUpdate;
|
||||
}
|
||||
handlers.handleToolCallUpdate(toolCallData);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'qwenSessionList': {
|
||||
const sessions = message.data.sessions || [];
|
||||
handlers.sessionManagement.setQwenSessions(sessions);
|
||||
if (
|
||||
handlers.sessionManagement.currentSessionId &&
|
||||
sessions.length > 0
|
||||
) {
|
||||
const currentSession = sessions.find(
|
||||
(s: Record<string, unknown>) =>
|
||||
(s.id as string) ===
|
||||
handlers.sessionManagement.currentSessionId ||
|
||||
(s.sessionId as string) ===
|
||||
handlers.sessionManagement.currentSessionId,
|
||||
);
|
||||
if (currentSession) {
|
||||
const title =
|
||||
(currentSession.title as string) ||
|
||||
(currentSession.name as string) ||
|
||||
'Past Conversations';
|
||||
handlers.sessionManagement.setCurrentSessionTitle(title);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'qwenSessionSwitched':
|
||||
handlers.sessionManagement.setShowSessionSelector(false);
|
||||
if (message.data.sessionId) {
|
||||
handlers.sessionManagement.setCurrentSessionId(
|
||||
message.data.sessionId as string,
|
||||
);
|
||||
}
|
||||
if (message.data.session) {
|
||||
const session = message.data.session as Record<string, unknown>;
|
||||
const title =
|
||||
(session.title as string) ||
|
||||
(session.name as string) ||
|
||||
'Past Conversations';
|
||||
handlers.sessionManagement.setCurrentSessionTitle(title);
|
||||
}
|
||||
if (message.data.messages) {
|
||||
handlers.messageHandling.setMessages(message.data.messages);
|
||||
} else {
|
||||
handlers.messageHandling.clearMessages();
|
||||
}
|
||||
handlers.clearToolCalls();
|
||||
handlers.setPlanEntries([]);
|
||||
break;
|
||||
|
||||
case 'conversationCleared':
|
||||
handlers.messageHandling.clearMessages();
|
||||
handlers.clearToolCalls();
|
||||
handlers.sessionManagement.setCurrentSessionId(null);
|
||||
handlers.sessionManagement.setCurrentSessionTitle(
|
||||
'Past Conversations',
|
||||
);
|
||||
break;
|
||||
|
||||
case 'sessionTitleUpdated': {
|
||||
const sessionId = message.data?.sessionId as string;
|
||||
const title = message.data?.title as string;
|
||||
if (sessionId && title) {
|
||||
handlers.sessionManagement.setCurrentSessionId(sessionId);
|
||||
handlers.sessionManagement.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;
|
||||
handlers.fileContext.setActiveFileName(fileName);
|
||||
handlers.fileContext.setActiveFilePath(filePath);
|
||||
handlers.fileContext.setActiveSelection(selection);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'fileAttached': {
|
||||
const attachment = message.data as {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
handlers.fileContext.addFileReference(
|
||||
attachment.name,
|
||||
attachment.value,
|
||||
);
|
||||
|
||||
if (inputFieldRef.current) {
|
||||
const currentText = inputFieldRef.current.textContent || '';
|
||||
const newText = currentText
|
||||
? `${currentText} @${attachment.name} `
|
||||
: `@${attachment.name} `;
|
||||
inputFieldRef.current.textContent = newText;
|
||||
setInputText(newText);
|
||||
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(inputFieldRef.current);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workspaceFiles': {
|
||||
const files = message.data?.files as Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}>;
|
||||
if (files) {
|
||||
handlers.fileContext.setWorkspaceFiles(files);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'saveSessionResponse': {
|
||||
handlers.sessionManagement.handleSaveSessionResponse(message.data);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[inputFieldRef, setInputText],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [handleMessage]);
|
||||
};
|
||||
Reference in New Issue
Block a user