mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(vscode): 重构消息排序和展示逻辑
- 移除旧的消息排序改进总结文档 - 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序 - 优化工具调用处理流程,添加时间戳支持 - 改进会话保存机制,直接使用SessionManager保存检查点 - 重构部分组件以提高可维护性
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -3693,6 +3693,34 @@
|
|||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@testing-library/react": {
|
||||||
|
"version": "16.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
||||||
|
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@testing-library/dom": "^10.0.0",
|
||||||
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@textlint/ast-node-types": {
|
"node_modules/@textlint/ast-node-types": {
|
||||||
"version": "15.2.2",
|
"version": "15.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
|
||||||
@@ -17143,34 +17171,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cli/node_modules/@testing-library/react": {
|
|
||||||
"version": "16.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
|
||||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@testing-library/dom": "^10.0.0",
|
|
||||||
"@types/react": "^18.0.0 || ^19.0.0",
|
|
||||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/cli/node_modules/string-width": {
|
"packages/cli/node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@@ -17345,7 +17345,6 @@
|
|||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
"@qwen-code/qwen-code": "file:../cli",
|
|
||||||
"@qwen-code/qwen-code-core": "file:../core",
|
"@qwen-code/qwen-code-core": "file:../core",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
# VS Code IDE Companion 消息排序改进总结
|
|
||||||
|
|
||||||
## 实施的改进
|
|
||||||
|
|
||||||
### 1. 添加时间戳支持
|
|
||||||
|
|
||||||
**文件修改:**
|
|
||||||
|
|
||||||
- `packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts`
|
|
||||||
- `packages/vscode-ide-companion/src/webview/types/toolCall.ts`
|
|
||||||
|
|
||||||
**改进内容:**
|
|
||||||
|
|
||||||
- 在 `ToolCallData` 接口中添加 `timestamp?: number` 字段
|
|
||||||
- 在 `ToolCallUpdate` 接口中添加 `timestamp?: number` 字段
|
|
||||||
|
|
||||||
### 2. 更新工具调用处理逻辑
|
|
||||||
|
|
||||||
**文件修改:**
|
|
||||||
|
|
||||||
- `packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts`
|
|
||||||
|
|
||||||
**改进内容:**
|
|
||||||
|
|
||||||
- 在创建工具调用时自动添加时间戳(使用提供的时间戳或当前时间)
|
|
||||||
- 在更新工具调用时保留原有时间戳或使用新提供的时间戳
|
|
||||||
|
|
||||||
### 3. 修改消息渲染逻辑
|
|
||||||
|
|
||||||
**文件修改:**
|
|
||||||
|
|
||||||
- `packages/vscode-ide-companion/src/webview/App.tsx`
|
|
||||||
|
|
||||||
**改进内容:**
|
|
||||||
|
|
||||||
- 将所有类型的消息(普通消息 + 工具调用)合并到一个数组中
|
|
||||||
- 按时间戳排序所有消息
|
|
||||||
- 统一渲染,确保工具调用在正确的时间点显示
|
|
||||||
|
|
||||||
## 解决的问题
|
|
||||||
|
|
||||||
### 1. 工具调用显示顺序不正确
|
|
||||||
|
|
||||||
**问题:** 工具调用总是显示在所有普通消息之后,而不是按时间顺序插入到正确的位置。
|
|
||||||
|
|
||||||
**解决方案:** 通过统一的时间戳排序机制,确保所有消息按时间顺序显示。
|
|
||||||
|
|
||||||
### 2. 缺少时间戳支持
|
|
||||||
|
|
||||||
**问题:** 工具调用数据结构中没有时间戳字段,无法正确排序。
|
|
||||||
|
|
||||||
**解决方案:** 在数据结构中添加时间戳字段,并在创建/更新时自动填充。
|
|
||||||
|
|
||||||
## 向后兼容性
|
|
||||||
|
|
||||||
所有改进都保持了向后兼容性:
|
|
||||||
|
|
||||||
- 对于没有时间戳的旧消息,使用当前时间作为默认值
|
|
||||||
- 现有 API 保持不变
|
|
||||||
- 现有功能不受影响
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
创建了相关测试用例:
|
|
||||||
|
|
||||||
- 验证工具调用时间戳的正确添加和保留
|
|
||||||
- 验证消息排序逻辑的正确性
|
|
||||||
- 验证工具调用显示条件的正确性
|
|
||||||
|
|
||||||
## 验收标准达成情况
|
|
||||||
|
|
||||||
✅ 所有新添加的时间戳支持都已实现
|
|
||||||
✅ 消息按照时间顺序正确排列
|
|
||||||
✅ 现有功能不受影响
|
|
||||||
✅ 代码质量符合项目标准
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
/* eslint-disable no-undef */
|
/* eslint-disable no-undef */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class AcpConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接到ACP后端
|
* Connect to ACP backend
|
||||||
*
|
*
|
||||||
* @param backend - Backend type
|
* @param backend - Backend type
|
||||||
* @param cliPath - CLI path
|
* @param cliPath - CLI path
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export class QwenAgentManager {
|
|||||||
private connectionHandler: QwenConnectionHandler;
|
private connectionHandler: QwenConnectionHandler;
|
||||||
private sessionUpdateHandler: QwenSessionUpdateHandler;
|
private sessionUpdateHandler: QwenSessionUpdateHandler;
|
||||||
private currentWorkingDir: string = process.cwd();
|
private currentWorkingDir: string = process.cwd();
|
||||||
|
// Cache the last used AuthStateManager so internal calls (e.g. fallback paths)
|
||||||
|
// can reuse it and avoid forcing a fresh authentication unnecessarily.
|
||||||
|
private defaultAuthStateManager?: AuthStateManager;
|
||||||
|
|
||||||
// Callback storage
|
// Callback storage
|
||||||
private callbacks: QwenAgentCallbacks = {};
|
private callbacks: QwenAgentCallbacks = {};
|
||||||
@@ -92,6 +95,8 @@ export class QwenAgentManager {
|
|||||||
_cliPath?: string,
|
_cliPath?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.currentWorkingDir = workingDir;
|
this.currentWorkingDir = workingDir;
|
||||||
|
// Remember the provided authStateManager for future calls
|
||||||
|
this.defaultAuthStateManager = authStateManager;
|
||||||
await this.connectionHandler.connect(
|
await this.connectionHandler.connect(
|
||||||
this.connection,
|
this.connection,
|
||||||
this.sessionReader,
|
this.sessionReader,
|
||||||
@@ -392,23 +397,25 @@ export class QwenAgentManager {
|
|||||||
'[QwenAgentManager] Current session ID (from CLI):',
|
'[QwenAgentManager] Current session ID (from CLI):',
|
||||||
this.currentSessionId,
|
this.currentSessionId,
|
||||||
);
|
);
|
||||||
|
// In ACP mode, the CLI does not accept arbitrary slash commands like
|
||||||
|
// "/chat save". To ensure we never block on unsupported features,
|
||||||
|
// persist checkpoints directly to ~/.qwen/tmp using our SessionManager.
|
||||||
|
const qwenMessages = messages.map((m) => ({
|
||||||
|
// Generate minimal QwenMessage shape expected by the writer
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: m.role === 'user' ? ('user' as const) : ('qwen' as const),
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
|
||||||
// Use CLI's /chat save command instead of manually writing files
|
const tag = await this.sessionManager.saveCheckpoint(
|
||||||
// This ensures we save the complete session context including tool calls
|
qwenMessages,
|
||||||
if (this.currentSessionId) {
|
conversationId,
|
||||||
console.log(
|
this.currentWorkingDir,
|
||||||
'[QwenAgentManager] Using CLI /chat save command for complete save',
|
this.currentSessionId || undefined,
|
||||||
);
|
);
|
||||||
return await this.saveCheckpointViaCommand(this.currentSessionId);
|
|
||||||
} else {
|
return { success: true, tag };
|
||||||
console.warn(
|
|
||||||
'[QwenAgentManager] No current session ID, cannot use /chat save',
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'No active CLI session',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
|
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
|
||||||
console.error('[QwenAgentManager] Error:', error);
|
console.error('[QwenAgentManager] Error:', error);
|
||||||
@@ -634,12 +641,12 @@ export class QwenAgentManager {
|
|||||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||||
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
|
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
|
||||||
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
|
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
|
||||||
|
// Prefer the provided authStateManager, otherwise fall back to the one
|
||||||
if (authStateManager) {
|
// remembered during connect(). This prevents accidental re-auth in
|
||||||
hasValidAuth = await authStateManager.hasValidAuth(
|
// fallback paths (e.g. session switching) when the handler didn't pass it.
|
||||||
workingDir,
|
const effectiveAuth = authStateManager || this.defaultAuthStateManager;
|
||||||
authMethod,
|
if (effectiveAuth) {
|
||||||
);
|
hasValidAuth = await effectiveAuth.hasValidAuth(workingDir, authMethod);
|
||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Has valid cached auth for new session:',
|
'[QwenAgentManager] Has valid cached auth for new session:',
|
||||||
hasValidAuth,
|
hasValidAuth,
|
||||||
@@ -656,20 +663,20 @@ export class QwenAgentManager {
|
|||||||
console.log('[QwenAgentManager] Authentication successful');
|
console.log('[QwenAgentManager] Authentication successful');
|
||||||
|
|
||||||
// Save auth state
|
// Save auth state
|
||||||
if (authStateManager) {
|
if (effectiveAuth) {
|
||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Saving auth state after successful authentication',
|
'[QwenAgentManager] Saving auth state after successful authentication',
|
||||||
);
|
);
|
||||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
await effectiveAuth.saveAuthState(workingDir, authMethod);
|
||||||
}
|
}
|
||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
console.error('[QwenAgentManager] Authentication failed:', authError);
|
console.error('[QwenAgentManager] Authentication failed:', authError);
|
||||||
// Clear potentially invalid cache
|
// Clear potentially invalid cache
|
||||||
if (authStateManager) {
|
if (effectiveAuth) {
|
||||||
console.log(
|
console.log(
|
||||||
'[QwenAgentManager] Clearing auth cache due to authentication failure',
|
'[QwenAgentManager] Clearing auth cache due to authentication failure',
|
||||||
);
|
);
|
||||||
await authStateManager.clearAuthState();
|
await effectiveAuth.clearAuthState();
|
||||||
}
|
}
|
||||||
throw authError;
|
throw authError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class QwenConnectionHandler {
|
|||||||
`[QwenAgentManager] CLI version ${versionInfo.version} is below minimum required version ${'0.2.4'}`,
|
`[QwenAgentManager] CLI version ${versionInfo.version} is below minimum required version ${'0.2.4'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: 暂时注释
|
// TODO: Wait to determine release version number
|
||||||
// vscode.window.showWarningMessage(
|
// vscode.window.showWarningMessage(
|
||||||
// `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version 0.2.4 or later.`,
|
// `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version 0.2.4 or later.`,
|
||||||
// );
|
// );
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { CliDetector } from '../cli/cliDetector.js';
|
import { CliDetector } from './cliDetector.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI Detection and Installation Handler
|
* CLI Detection and Installation Handler
|
||||||
@@ -62,6 +62,8 @@ export const App: React.FC = () => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Scroll container for message list; used to keep the view anchored to the latest content
|
||||||
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputFieldRef = useRef<HTMLDivElement>(null);
|
const inputFieldRef = useRef<HTMLDivElement>(null);
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
const [showBanner, setShowBanner] = useState(true);
|
||||||
const [editMode, setEditMode] = useState<EditMode>('ask');
|
const [editMode, setEditMode] = useState<EditMode>('ask');
|
||||||
@@ -173,6 +175,51 @@ export const App: React.FC = () => {
|
|||||||
setInputText,
|
setInputText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||||
|
// but don't interrupt the user if they scrolled up.
|
||||||
|
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
|
||||||
|
useEffect(() => {
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
const endEl = messagesEndRef.current;
|
||||||
|
if (!container || !endEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearBottom = () => {
|
||||||
|
const threshold = 64; // px tolerance
|
||||||
|
return (
|
||||||
|
container.scrollTop + container.clientHeight >=
|
||||||
|
container.scrollHeight - threshold
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect whether new items were appended (vs. streaming chunk updates)
|
||||||
|
const prev = prevCountsRef.current;
|
||||||
|
const newMsg = messageHandling.messages.length > prev.msgLen;
|
||||||
|
const newInProg = inProgressToolCalls.length > prev.inProgLen;
|
||||||
|
const newDone = completedToolCalls.length > prev.doneLen;
|
||||||
|
prevCountsRef.current = {
|
||||||
|
msgLen: messageHandling.messages.length,
|
||||||
|
inProgLen: inProgressToolCalls.length,
|
||||||
|
doneLen: completedToolCalls.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If user is near bottom, or if we just appended a new item, scroll to bottom
|
||||||
|
if (nearBottom() || newMsg || newInProg || newDone) {
|
||||||
|
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
|
||||||
|
endEl.scrollIntoView({
|
||||||
|
behavior: smooth ? 'smooth' : 'auto',
|
||||||
|
block: 'end',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
messageHandling.messages,
|
||||||
|
inProgressToolCalls,
|
||||||
|
completedToolCalls,
|
||||||
|
messageHandling.isWaitingForResponse,
|
||||||
|
messageHandling.loadingMessage,
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle permission response
|
// Handle permission response
|
||||||
const handlePermissionResponse = useCallback(
|
const handlePermissionResponse = useCallback(
|
||||||
(optionId: string) => {
|
(optionId: string) => {
|
||||||
@@ -380,6 +427,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={messagesContainerRef}
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>.message-item]:px-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
className="flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>.message-item]:px-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
||||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||||
>
|
>
|
||||||
@@ -387,23 +435,23 @@ export const App: React.FC = () => {
|
|||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 创建统一的消息数组,包含所有类型的消息和工具调用 */}
|
{/* Create unified message array containing all types of messages and tool calls */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// 普通消息
|
// Regular messages
|
||||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||||
type: 'message' as const,
|
type: 'message' as const,
|
||||||
data: msg,
|
data: msg,
|
||||||
timestamp: msg.timestamp,
|
timestamp: msg.timestamp,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 进行中的工具调用
|
// In-progress tool calls
|
||||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||||
type: 'in-progress-tool-call' as const,
|
type: 'in-progress-tool-call' as const,
|
||||||
data: toolCall,
|
data: toolCall,
|
||||||
timestamp: toolCall.timestamp || Date.now(),
|
timestamp: toolCall.timestamp || Date.now(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 完成的工具调用
|
// Completed tool calls
|
||||||
const completedTools = completedToolCalls
|
const completedTools = completedToolCalls
|
||||||
.filter(hasToolCallOutput)
|
.filter(hasToolCallOutput)
|
||||||
.map((toolCall) => ({
|
.map((toolCall) => ({
|
||||||
@@ -412,7 +460,7 @@ export const App: React.FC = () => {
|
|||||||
timestamp: toolCall.timestamp || Date.now(),
|
timestamp: toolCall.timestamp || Date.now(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 合并并按时间戳排序,确保消息与工具调用穿插显示
|
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||||
const allMessages = [
|
const allMessages = [
|
||||||
...regularMessages,
|
...regularMessages,
|
||||||
...inProgressTools,
|
...inProgressTools,
|
||||||
@@ -492,7 +540,7 @@ export const App: React.FC = () => {
|
|||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 已改为在 useWebViewMessages 中将每次 plan 推送为历史 toolcall,避免重复展示最新块 */}
|
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}
|
||||||
|
|
||||||
{messageHandling.isWaitingForResponse &&
|
{messageHandling.isWaitingForResponse &&
|
||||||
messageHandling.loadingMessage && (
|
messageHandling.loadingMessage && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AuthStateManager } from '../auth/authStateManager.js';
|
|||||||
import { PanelManager } from '../webview/PanelManager.js';
|
import { PanelManager } from '../webview/PanelManager.js';
|
||||||
import { MessageHandler } from '../webview/MessageHandler.js';
|
import { MessageHandler } from '../webview/MessageHandler.js';
|
||||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||||
import { CliInstaller } from '../cli/CliInstaller.js';
|
import { CliInstaller } from '../cli/1cliInstaller.js';
|
||||||
import { getFileName } from './utils/webviewUtils.js';
|
import { getFileName } from './utils/webviewUtils.js';
|
||||||
|
|
||||||
export class WebViewProvider {
|
export class WebViewProvider {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PlanDisplayProps {
|
|||||||
* PlanDisplay component - displays AI's task plan/todo list
|
* PlanDisplay component - displays AI's task plan/todo list
|
||||||
*/
|
*/
|
||||||
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||||
// 计算整体状态用于左侧圆点颜色
|
// Calculate overall status for left dot color
|
||||||
const allCompleted =
|
const allCompleted =
|
||||||
entries.length > 0 && entries.every((e) => e.status === 'completed');
|
entries.length > 0 && entries.every((e) => e.status === 'completed');
|
||||||
const anyInProgress = entries.some((e) => e.status === 'in_progress');
|
const anyInProgress = entries.some((e) => e.status === 'in_progress');
|
||||||
@@ -36,14 +36,14 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'plan-display',
|
'plan-display',
|
||||||
// 容器:类似示例中的 .A/.e
|
// Container: Similar to example .A/.e
|
||||||
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
|
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
|
||||||
// 左侧状态圆点,类似示例 .e:before
|
// Left status dot, similar to example .e:before
|
||||||
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
|
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
|
||||||
statusDotClass,
|
statusDotClass,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* 标题区域,类似示例中的 summary/_e/or */}
|
{/* Title area, similar to example summary/_e/or */}
|
||||||
<div className="plan-header w-full">
|
<div className="plan-header w-full">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
|
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
|
||||||
@@ -56,7 +56,7 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表区域,类似示例中的 .qr/.Fr/.Hr */}
|
{/* List area, similar to example .qr/.Fr/.Hr */}
|
||||||
<div className="qr grid-cols-1 flex flex-col py-2">
|
<div className="qr grid-cols-1 flex flex-col py-2">
|
||||||
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||||
{entries.map((entry, index) => {
|
{entries.map((entry, index) => {
|
||||||
@@ -70,7 +70,7 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
|||||||
isDone ? 'fo opacity-70' : '',
|
isDone ? 'fo opacity-70' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/* 展示用复选框(复用组件) */}
|
{/* Display checkbox (reusable component) */}
|
||||||
<label className="flex items-start gap-2">
|
<label className="flex items-start gap-2">
|
||||||
<CheckboxDisplay
|
<CheckboxDisplay
|
||||||
checked={isDone}
|
checked={isDone}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
/* Save Session Dialog Styles */
|
/* Save Session Dialog Styles */
|
||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
/* Session Manager Styles */
|
/* Session Manager Styles */
|
||||||
.session-manager {
|
.session-manager {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ export type TimelineItemType =
|
|||||||
export interface TimelineItemProps {
|
export interface TimelineItemProps {
|
||||||
type: TimelineItemType;
|
type: TimelineItemType;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** 是否可折叠(主要用于工具输出) */
|
/** Whether collapsible (mainly for tool output) */
|
||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
/** 默认是否展开 */
|
/** Default expanded */
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
/** 自定义标题(用于折叠时显示) */
|
/** Custom title (used for display when collapsed) */
|
||||||
title?: string;
|
title?: string;
|
||||||
/** 是否是最后一个项目 */
|
/** Whether it is the last item */
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeline 项目组件 - 统一展示消息和工具调用
|
* Timeline item component - Unified display of messages and tool calls
|
||||||
*/
|
*/
|
||||||
export const TimelineItem: React.FC<TimelineItemProps> = ({
|
export const TimelineItem: React.FC<TimelineItemProps> = ({
|
||||||
type,
|
type,
|
||||||
@@ -44,15 +44,15 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
|||||||
const getDotColor = (): string => {
|
const getDotColor = (): string => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'user-message':
|
case 'user-message':
|
||||||
return 'blue'; // 用户消息 - 蓝色
|
return 'blue'; // User message - Blue
|
||||||
case 'assistant-message':
|
case 'assistant-message':
|
||||||
return 'gray'; // LLM 输出 - 灰色
|
return 'gray'; // LLM output - Gray
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
return 'green'; // 工具调用 - 绿色
|
return 'green'; // Tool call - Green
|
||||||
case 'tool-output':
|
case 'tool-output':
|
||||||
return 'gray'; // 工具输出 - 灰色
|
return 'gray'; // Tool output - Gray
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return 'purple'; // 思考 - 紫色
|
return 'purple'; // Thinking - Purple
|
||||||
default:
|
default:
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
@@ -80,20 +80,20 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`timeline-item ${type} ${isLast ? 'last' : ''}`}>
|
<div className={`timeline-item ${type} ${isLast ? 'last' : ''}`}>
|
||||||
{/* 时间线连接线 - 暂时禁用 */}
|
{/* Timeline connecting line - Temporarily disabled */}
|
||||||
{/* {!isLast && <div className="timeline-line" />} */}
|
{/* {!isLast && <div className="timeline-line" />} */}
|
||||||
|
|
||||||
{/* 状态圆点 */}
|
{/* Status dot */}
|
||||||
<div className={`timeline-dot ${dotColor}`}>
|
<div className={`timeline-dot ${dotColor}`}>
|
||||||
<span className="dot-inner" />
|
<span className="dot-inner" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* Content area */}
|
||||||
<div className="timeline-content">
|
<div className="timeline-content">
|
||||||
{/* 标签(可选) */}
|
{/* Label (optional) */}
|
||||||
{itemLabel && <div className="timeline-label">{itemLabel}</div>}
|
{itemLabel && <div className="timeline-label">{itemLabel}</div>}
|
||||||
|
|
||||||
{/* 可折叠内容 */}
|
{/* Collapsible content */}
|
||||||
{collapsible ? (
|
{collapsible ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -119,7 +119,7 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeline 容器组件
|
* Timeline container component
|
||||||
*/
|
*/
|
||||||
export const Timeline: React.FC<{ children: React.ReactNode }> = ({
|
export const Timeline: React.FC<{ children: React.ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -48,31 +48,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
|
|
||||||
{/* Save Session Button */}
|
|
||||||
{/* <button
|
|
||||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
|
||||||
style={{
|
|
||||||
color: 'var(--app-primary-foreground)',
|
|
||||||
}}
|
|
||||||
onClick={onSaveSession}
|
|
||||||
title="Save Conversation"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
data-slot="icon"
|
|
||||||
className="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button> */}
|
|
||||||
|
|
||||||
{/* New Session Button */}
|
{/* New Session Button */}
|
||||||
<button
|
<button
|
||||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
onFileClick,
|
onFileClick,
|
||||||
status = 'default',
|
status = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
// 空内容直接不渲染,避免只显示 ::before 的圆点导致观感不佳
|
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
|
||||||
if (!content || content.trim().length === 0) {
|
if (!content || content.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Use explicit Vitest imports instead of relying on globals.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { ToolCallData } from '../toolcalls/shared/types.js';
|
import type { ToolCallData } from '../toolcalls/shared/types.js';
|
||||||
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
|
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ interface SessionSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话选择器组件
|
* Session selector component
|
||||||
* 显示会话列表并支持搜索和选择
|
* Display session list and support search and selection
|
||||||
*/
|
*/
|
||||||
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
||||||
visible,
|
visible,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const mapToolStatusToBullet = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从文本中尽可能解析带有 - [ ] / - [x] 的 todo 列表
|
// Parse todo list with - [ ] / - [x] from text as much as possible
|
||||||
const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
||||||
const text = textOutputs.join('\n');
|
const text = textOutputs.join('\n');
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
@@ -60,7 +60,7 @@ const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没匹配到,退化为将非空行当作 pending 条目
|
// If no match is found, fall back to treating non-empty lines as pending items
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const title = line.trim();
|
const title = line.trim();
|
||||||
@@ -83,7 +83,7 @@ export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
|||||||
const { content, status } = toolCall;
|
const { content, status } = toolCall;
|
||||||
const { errors, textOutputs } = groupContent(content);
|
const { errors, textOutputs } = groupContent(content);
|
||||||
|
|
||||||
// 错误优先展示
|
// Error-first display
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer label="Update Todos" status="error">
|
<ToolCallContainer label="Update Todos" status="error">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface DiffDisplayProps {
|
|||||||
oldText?: string | null;
|
oldText?: string | null;
|
||||||
newText?: string;
|
newText?: string;
|
||||||
onOpenDiff?: () => void;
|
onOpenDiff?: () => void;
|
||||||
/** 是否显示统计信息 */
|
/** Whether to display statistics */
|
||||||
showStats?: boolean;
|
showStats?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|||||||
onOpenDiff,
|
onOpenDiff,
|
||||||
showStats = true,
|
showStats = true,
|
||||||
}) => {
|
}) => {
|
||||||
// 统计信息(仅在文本变化时重新计算)
|
// Statistics (recalculate only when text changes)
|
||||||
const stats = useMemo(
|
const stats = useMemo(
|
||||||
() => calculateDiffStats(oldText, newText),
|
() => calculateDiffStats(oldText, newText),
|
||||||
[oldText, newText],
|
[oldText, newText],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 仅生成变更行(增加/删除),不渲染上下文
|
// Only generate changed lines (additions/deletions), do not render context
|
||||||
const ops: DiffOp[] = useMemo(
|
const ops: DiffOp[] = useMemo(
|
||||||
() => computeLineDiff(oldText, newText),
|
() => computeLineDiff(oldText, newText),
|
||||||
[oldText, newText],
|
[oldText, newText],
|
||||||
@@ -106,7 +106,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 只绘制差异行的预览区域 */}
|
{/* Only draw preview area for diff lines */}
|
||||||
<pre className="diff-preview code-block" aria-label="Diff preview">
|
<pre className="diff-preview code-block" aria-label="Diff preview">
|
||||||
<div className="code-content">
|
<div className="code-content">
|
||||||
{previewOps.length === 0 && (
|
{previewOps.length === 0 && (
|
||||||
@@ -142,7 +142,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
{/* 在预览下方提供显式打开按钮(可选) */}
|
{/* Provide explicit open button below preview (optional) */}
|
||||||
{onOpenDiff && (
|
{onOpenDiff && (
|
||||||
<div className="diff-compact-actions">
|
<div className="diff-compact-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export interface ToolCallData {
|
|||||||
rawInput?: string | object;
|
rawInput?: string | object;
|
||||||
content?: ToolCallContent[];
|
content?: ToolCallContent[];
|
||||||
locations?: ToolCallLocation[];
|
locations?: ToolCallLocation[];
|
||||||
timestamp?: number; // 添加时间戳字段用于消息排序
|
timestamp?: number; // Add a timestamp field for message sorting
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*
|
|
||||||
* Button component using Tailwind CSS
|
|
||||||
* This is an example of how to create new components using Tailwind
|
|
||||||
* while maintaining compatibility with existing CSS-based components
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type React from 'react';
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
/**
|
|
||||||
* Button variant style
|
|
||||||
*/
|
|
||||||
variant?: 'primary' | 'secondary' | 'ghost' | 'icon';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button size
|
|
||||||
*/
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button contents
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional click handler
|
|
||||||
*/
|
|
||||||
onClick?: () => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable button
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional class names
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Primary UI component for user interaction
|
|
||||||
*/
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
|
||||||
variant = 'primary',
|
|
||||||
size = 'md',
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
// Base classes that apply to all buttons
|
|
||||||
const baseClasses = "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
|
|
||||||
|
|
||||||
// Variant-specific classes
|
|
||||||
const variantClasses = {
|
|
||||||
primary: "bg-qwen-orange text-qwen-ivory hover:bg-qwen-clay-orange shadow-sm",
|
|
||||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700",
|
|
||||||
ghost: "hover:bg-gray-100 dark:hover:bg-gray-800",
|
|
||||||
icon: "hover:bg-gray-100 dark:hover:bg-gray-800 p-1"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Size-specific classes
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: "h-8 px-3 text-xs",
|
|
||||||
md: "h-10 px-4 py-2 text-sm",
|
|
||||||
lg: "h-12 px-6 text-base"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Combine all classes
|
|
||||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classes}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*
|
|
||||||
* Card component using Tailwind CSS
|
|
||||||
* This demonstrates how to create new components with Tailwind
|
|
||||||
* while maintaining compatibility with existing components
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type React from 'react';
|
|
||||||
|
|
||||||
interface CardProps {
|
|
||||||
/**
|
|
||||||
* Card contents
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional class names
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardHeaderProps {
|
|
||||||
/**
|
|
||||||
* Card header contents
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional class names
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardContentProps {
|
|
||||||
/**
|
|
||||||
* Card content contents
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional class names
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardFooterProps {
|
|
||||||
/**
|
|
||||||
* Card footer contents
|
|
||||||
*/
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional class names
|
|
||||||
*/
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card container component
|
|
||||||
*/
|
|
||||||
const Card: React.FC<CardProps> & {
|
|
||||||
Header: React.FC<CardHeaderProps>;
|
|
||||||
Content: React.FC<CardContentProps>;
|
|
||||||
Footer: React.FC<CardFooterProps>;
|
|
||||||
} = ({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`rounded-lg border bg-card text-card-foreground shadow-sm ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card header component
|
|
||||||
*/
|
|
||||||
const CardHeader: React.FC<CardHeaderProps> = ({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card content component
|
|
||||||
*/
|
|
||||||
const CardContent: React.FC<CardContentProps> = ({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`p-6 pt-0 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card footer component
|
|
||||||
*/
|
|
||||||
const CardFooter: React.FC<CardFooterProps> = ({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center p-6 pt-0 ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compose the Card component with its subcomponents
|
|
||||||
Card.Header = CardHeader;
|
|
||||||
Card.Content = CardContent;
|
|
||||||
Card.Footer = CardFooter;
|
|
||||||
|
|
||||||
export { Card };
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type React from 'react';
|
|
||||||
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
|
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
|
||||||
currentSessionTitle: string;
|
|
||||||
onLoadSessions: () => void;
|
|
||||||
onSaveSession: () => void;
|
|
||||||
onNewSession: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|
||||||
currentSessionTitle,
|
|
||||||
onLoadSessions,
|
|
||||||
onSaveSession: _onSaveSession,
|
|
||||||
onNewSession,
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className="flex gap-1 select-none py-1.5 px-2.5"
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid var(--app-primary-border-color)',
|
|
||||||
backgroundColor: 'var(--app-header-background)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Past Conversations Button */}
|
|
||||||
<button
|
|
||||||
className="flex-none py-1 px-2 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none font-medium transition-colors duration-200 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
|
||||||
style={{
|
|
||||||
borderRadius: 'var(--corner-radius-small)',
|
|
||||||
color: 'var(--app-primary-foreground)',
|
|
||||||
fontSize: 'var(--vscode-chat-font-size, 13px)',
|
|
||||||
}}
|
|
||||||
onClick={onLoadSessions}
|
|
||||||
title="Past conversations"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
|
|
||||||
{currentSessionTitle}
|
|
||||||
</span>
|
|
||||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div className="flex-1"></div>
|
|
||||||
|
|
||||||
{/* Save Session Button */}
|
|
||||||
{/* <button
|
|
||||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
|
||||||
style={{
|
|
||||||
color: 'var(--app-primary-foreground)',
|
|
||||||
}}
|
|
||||||
onClick={onSaveSession}
|
|
||||||
title="Save Conversation"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
data-slot="icon"
|
|
||||||
className="w-4 h-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</button> */}
|
|
||||||
|
|
||||||
{/* New Session Button */}
|
|
||||||
<button
|
|
||||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
|
||||||
style={{
|
|
||||||
color: 'var(--app-primary-foreground)',
|
|
||||||
}}
|
|
||||||
onClick={onNewSession}
|
|
||||||
title="New Session"
|
|
||||||
>
|
|
||||||
<PlusIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -25,6 +25,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
'getQwenSessions',
|
'getQwenSessions',
|
||||||
'saveSession',
|
'saveSession',
|
||||||
'resumeSession',
|
'resumeSession',
|
||||||
|
// UI action: open a new chat tab (new WebviewPanel)
|
||||||
|
'openNewChatTab',
|
||||||
].includes(messageType);
|
].includes(messageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +84,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
|||||||
await this.handleResumeSession((data?.sessionId as string) || '');
|
await this.handleResumeSession((data?.sessionId as string) || '');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'openNewChatTab':
|
||||||
|
// Open a brand new chat tab (WebviewPanel) via the extension command
|
||||||
|
// This does not alter the current conversation in this tab; the new tab
|
||||||
|
// will initialize its own state and (optionally) create a new session.
|
||||||
|
try {
|
||||||
|
await vscode.commands.executeCommand('qwenCode.openNewChatTab');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
'[SessionMessageHandler] Failed to open new chat tab:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.sendToWebView({
|
||||||
|
type: 'error',
|
||||||
|
data: { message: `Failed to open new chat tab: ${error}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(
|
console.warn(
|
||||||
'[SessionMessageHandler] Unknown message type:',
|
'[SessionMessageHandler] Unknown message type:',
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { useCompletionTrigger } from './useCompletionTrigger';
|
|
||||||
|
|
||||||
// Mock CompletionItem type
|
|
||||||
interface CompletionItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
type: 'file' | 'symbol' | 'command' | 'variable';
|
|
||||||
value?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useCompletionTrigger', () => {
|
|
||||||
let mockInputRef: React.RefObject<HTMLDivElement>;
|
|
||||||
let mockGetCompletionItems: (
|
|
||||||
trigger: '@' | '/',
|
|
||||||
query: string,
|
|
||||||
) => Promise<CompletionItem[]>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockInputRef = {
|
|
||||||
current: document.createElement('div'),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockGetCompletionItems = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should trigger completion when @ is typed at word boundary', async () => {
|
|
||||||
mockGetCompletionItems.mockResolvedValue([
|
|
||||||
{ id: '1', label: 'test.txt', type: 'file' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate typing @ at the beginning
|
|
||||||
mockInputRef.current.textContent = '@';
|
|
||||||
|
|
||||||
// Mock window.getSelection to return a valid range
|
|
||||||
const mockRange = {
|
|
||||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getSelection = jest.fn().mockReturnValue({
|
|
||||||
rangeCount: 1,
|
|
||||||
getRangeAt: () => mockRange,
|
|
||||||
} as unknown as Selection);
|
|
||||||
|
|
||||||
// Trigger input event
|
|
||||||
await act(async () => {
|
|
||||||
const event = new Event('input', { bubbles: true });
|
|
||||||
mockInputRef.current.dispatchEvent(event);
|
|
||||||
// Wait for async operations
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
expect(result.current.triggerChar).toBe('@');
|
|
||||||
expect(mockGetCompletionItems).toHaveBeenCalledWith('@', '');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading state initially', async () => {
|
|
||||||
// Simulate slow file loading
|
|
||||||
mockGetCompletionItems.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) =>
|
|
||||||
setTimeout(
|
|
||||||
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate typing @ at the beginning
|
|
||||||
mockInputRef.current.textContent = '@';
|
|
||||||
|
|
||||||
const mockRange = {
|
|
||||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getSelection = jest.fn().mockReturnValue({
|
|
||||||
rangeCount: 1,
|
|
||||||
getRangeAt: () => mockRange,
|
|
||||||
} as unknown as Selection);
|
|
||||||
|
|
||||||
// Trigger input event
|
|
||||||
await act(async () => {
|
|
||||||
const event = new Event('input', { bubbles: true });
|
|
||||||
mockInputRef.current.dispatchEvent(event);
|
|
||||||
// Wait for async operations but not for the slow promise
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show loading state immediately
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
expect(result.current.items).toHaveLength(1);
|
|
||||||
expect(result.current.items[0].id).toBe('loading');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should timeout if loading takes too long', async () => {
|
|
||||||
// Simulate very slow file loading
|
|
||||||
mockGetCompletionItems.mockImplementation(
|
|
||||||
() =>
|
|
||||||
new Promise(
|
|
||||||
(resolve) =>
|
|
||||||
setTimeout(
|
|
||||||
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
|
|
||||||
10000,
|
|
||||||
), // 10 seconds
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate typing @ at the beginning
|
|
||||||
mockInputRef.current.textContent = '@';
|
|
||||||
|
|
||||||
const mockRange = {
|
|
||||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getSelection = jest.fn().mockReturnValue({
|
|
||||||
rangeCount: 1,
|
|
||||||
getRangeAt: () => mockRange,
|
|
||||||
} as unknown as Selection);
|
|
||||||
|
|
||||||
// Trigger input event
|
|
||||||
await act(async () => {
|
|
||||||
const event = new Event('input', { bubbles: true });
|
|
||||||
mockInputRef.current.dispatchEvent(event);
|
|
||||||
// Wait for async operations
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show loading state initially
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
expect(result.current.items).toHaveLength(1);
|
|
||||||
expect(result.current.items[0].id).toBe('loading');
|
|
||||||
|
|
||||||
// Wait for timeout (5 seconds)
|
|
||||||
await act(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5100)); // 5.1 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show timeout message
|
|
||||||
expect(result.current.items).toHaveLength(1);
|
|
||||||
expect(result.current.items[0].id).toBe('timeout');
|
|
||||||
expect(result.current.items[0].label).toBe('Timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should close completion when cursor moves away from trigger', async () => {
|
|
||||||
mockGetCompletionItems.mockResolvedValue([
|
|
||||||
{ id: '1', label: 'test.txt', type: 'file' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate typing @ at the beginning
|
|
||||||
mockInputRef.current.textContent = '@';
|
|
||||||
|
|
||||||
const mockRange = {
|
|
||||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
window.getSelection = jest.fn().mockReturnValue({
|
|
||||||
rangeCount: 1,
|
|
||||||
getRangeAt: () => mockRange,
|
|
||||||
} as unknown as Selection);
|
|
||||||
|
|
||||||
// Trigger input event to open completion
|
|
||||||
await act(async () => {
|
|
||||||
const event = new Event('input', { bubbles: true });
|
|
||||||
mockInputRef.current.dispatchEvent(event);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isOpen).toBe(true);
|
|
||||||
|
|
||||||
// Simulate moving cursor away (typing space after @)
|
|
||||||
mockInputRef.current.textContent = '@ ';
|
|
||||||
|
|
||||||
// Trigger input event to close completion
|
|
||||||
await act(async () => {
|
|
||||||
const event = new Event('input', { bubbles: true });
|
|
||||||
mockInputRef.current.dispatchEvent(event);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should close completion when query contains space
|
|
||||||
expect(result.current.isOpen).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import type { CompletionItem } from '../components/CompletionMenu.js';
|
import type { CompletionItem } from '../components/CompletionMenu.js';
|
||||||
|
|
||||||
interface CompletionTriggerState {
|
interface CompletionTriggerState {
|
||||||
@@ -27,6 +27,26 @@ export function useCompletionTrigger(
|
|||||||
query: string,
|
query: string,
|
||||||
) => Promise<CompletionItem[]>,
|
) => Promise<CompletionItem[]>,
|
||||||
) {
|
) {
|
||||||
|
// Show immediate loading and provide a timeout fallback for slow sources
|
||||||
|
const LOADING_ITEM = useMemo<CompletionItem>(
|
||||||
|
() => ({
|
||||||
|
id: 'loading',
|
||||||
|
label: 'Loading…',
|
||||||
|
type: 'info',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const TIMEOUT_ITEM = useMemo<CompletionItem>(
|
||||||
|
() => ({
|
||||||
|
id: 'timeout',
|
||||||
|
label: 'Timeout',
|
||||||
|
type: 'info',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
const [state, setState] = useState<CompletionTriggerState>({
|
const [state, setState] = useState<CompletionTriggerState>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
triggerChar: null,
|
triggerChar: null,
|
||||||
@@ -35,7 +55,15 @@ export function useCompletionTrigger(
|
|||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Timer for loading timeout
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const closeCompletion = useCallback(() => {
|
const closeCompletion = useCallback(() => {
|
||||||
|
// Clear pending timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
setState({
|
setState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
triggerChar: null,
|
triggerChar: null,
|
||||||
@@ -51,16 +79,56 @@ export function useCompletionTrigger(
|
|||||||
query: string,
|
query: string,
|
||||||
position: { top: number; left: number },
|
position: { top: number; left: number },
|
||||||
) => {
|
) => {
|
||||||
const items = await getCompletionItems(trigger, query);
|
// Clear previous timeout if any
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open immediately with a loading placeholder
|
||||||
setState({
|
setState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
triggerChar: trigger,
|
triggerChar: trigger,
|
||||||
query,
|
query,
|
||||||
position,
|
position,
|
||||||
items,
|
items: [LOADING_ITEM],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Schedule a timeout fallback if loading takes too long
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
// Only show timeout if still open and still for the same request
|
||||||
|
if (
|
||||||
|
prev.isOpen &&
|
||||||
|
prev.triggerChar === trigger &&
|
||||||
|
prev.query === query &&
|
||||||
|
prev.items.length > 0 &&
|
||||||
|
prev.items[0]?.id === 'loading'
|
||||||
|
) {
|
||||||
|
return { ...prev, items: [TIMEOUT_ITEM] };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
|
||||||
|
const items = await getCompletionItems(trigger, query);
|
||||||
|
|
||||||
|
// Clear timeout on success
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isOpen: true,
|
||||||
|
triggerChar: trigger,
|
||||||
|
query,
|
||||||
|
position,
|
||||||
|
items,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
[getCompletionItems],
|
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCompletion = useCallback(async () => {
|
const refreshCompletion = useCallback(async () => {
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Qwen Team
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { useToolCalls } from './useToolCalls';
|
|
||||||
import type { ToolCallUpdate } from '../types/toolCall.js';
|
|
||||||
|
|
||||||
describe('useToolCalls', () => {
|
|
||||||
it('should add timestamp when creating tool call', () => {
|
|
||||||
const { result } = renderHook(() => useToolCalls());
|
|
||||||
|
|
||||||
const toolCallUpdate: ToolCallUpdate = {
|
|
||||||
type: 'tool_call',
|
|
||||||
toolCallId: 'test-1',
|
|
||||||
kind: 'read',
|
|
||||||
title: 'Read file',
|
|
||||||
status: 'pending',
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
|
||||||
expect(toolCalls).toHaveLength(1);
|
|
||||||
expect(toolCalls[0].timestamp).toBeDefined();
|
|
||||||
expect(typeof toolCalls[0].timestamp).toBe('number');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve timestamp when updating tool call', () => {
|
|
||||||
const { result } = renderHook(() => useToolCalls());
|
|
||||||
|
|
||||||
const timestamp = Date.now() - 1000; // 1 second ago
|
|
||||||
|
|
||||||
// Create tool call with specific timestamp
|
|
||||||
const toolCallUpdate: ToolCallUpdate = {
|
|
||||||
type: 'tool_call',
|
|
||||||
toolCallId: 'test-1',
|
|
||||||
kind: 'read',
|
|
||||||
title: 'Read file',
|
|
||||||
status: 'pending',
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update tool call without timestamp
|
|
||||||
const toolCallUpdate2: ToolCallUpdate = {
|
|
||||||
type: 'tool_call_update',
|
|
||||||
toolCallId: 'test-1',
|
|
||||||
status: 'completed',
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleToolCallUpdate(toolCallUpdate2);
|
|
||||||
});
|
|
||||||
|
|
||||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
|
||||||
expect(toolCalls).toHaveLength(1);
|
|
||||||
expect(toolCalls[0].timestamp).toBe(timestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use current time as default timestamp', () => {
|
|
||||||
const { result } = renderHook(() => useToolCalls());
|
|
||||||
|
|
||||||
const before = Date.now();
|
|
||||||
|
|
||||||
const toolCallUpdate: ToolCallUpdate = {
|
|
||||||
type: 'tool_call',
|
|
||||||
toolCallId: 'test-1',
|
|
||||||
kind: 'read',
|
|
||||||
title: 'Read file',
|
|
||||||
status: 'pending',
|
|
||||||
// No timestamp provided
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
|
||||||
});
|
|
||||||
|
|
||||||
const after = Date.now();
|
|
||||||
|
|
||||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
|
||||||
expect(toolCalls).toHaveLength(1);
|
|
||||||
expect(toolCalls[0].timestamp).toBeGreaterThanOrEqual(before);
|
|
||||||
expect(toolCalls[0].timestamp).toBeLessThanOrEqual(after);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -108,11 +108,11 @@ export const useToolCalls = () => {
|
|||||||
newText: item.newText,
|
newText: item.newText,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 合并策略:对于 todo_write + mergeable 标题(Updated Plan/Update Todos),
|
// Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos),
|
||||||
// 如果与最近一条同类卡片相同或是补充,则合并更新而不是新增。
|
// if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new.
|
||||||
if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) {
|
if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) {
|
||||||
const nextText = extractText(content);
|
const nextText = extractText(content);
|
||||||
// 找最近一条 todo_write + 可合并标题 的卡片
|
// Find the most recent card with todo_write + mergeable title
|
||||||
let lastId: string | null = null;
|
let lastId: string | null = null;
|
||||||
let lastText = '';
|
let lastText = '';
|
||||||
let lastTimestamp = 0;
|
let lastTimestamp = 0;
|
||||||
@@ -132,16 +132,16 @@ export const useToolCalls = () => {
|
|||||||
if (lastId) {
|
if (lastId) {
|
||||||
const cmp = isSameOrSupplement(lastText, nextText);
|
const cmp = isSameOrSupplement(lastText, nextText);
|
||||||
if (cmp.same) {
|
if (cmp.same) {
|
||||||
// 完全相同:忽略本次新增
|
// Completely identical: Ignore this addition
|
||||||
return newMap;
|
return newMap;
|
||||||
}
|
}
|
||||||
if (cmp.supplement) {
|
if (cmp.supplement) {
|
||||||
// 补充:替换内容到上一条(使用更新语义)
|
// Supplement: Replace content to the previous item (using update semantics)
|
||||||
const prev = newMap.get(lastId);
|
const prev = newMap.get(lastId);
|
||||||
if (prev) {
|
if (prev) {
|
||||||
newMap.set(lastId, {
|
newMap.set(lastId, {
|
||||||
...prev,
|
...prev,
|
||||||
content, // 覆盖(不追加)
|
content, // Override (do not append)
|
||||||
status: update.status || prev.status,
|
status: update.status || prev.status,
|
||||||
timestamp: update.timestamp || Date.now(),
|
timestamp: update.timestamp || Date.now(),
|
||||||
});
|
});
|
||||||
@@ -159,7 +159,7 @@ export const useToolCalls = () => {
|
|||||||
rawInput: update.rawInput as string | object | undefined,
|
rawInput: update.rawInput as string | object | undefined,
|
||||||
content,
|
content,
|
||||||
locations: update.locations,
|
locations: update.locations,
|
||||||
timestamp: update.timestamp || Date.now(), // 添加时间戳
|
timestamp: update.timestamp || Date.now(), // Add timestamp
|
||||||
});
|
});
|
||||||
} else if (update.type === 'tool_call_update') {
|
} else if (update.type === 'tool_call_update') {
|
||||||
const updatedContent = update.content
|
const updatedContent = update.content
|
||||||
@@ -173,7 +173,7 @@ export const useToolCalls = () => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// 默认行为是追加;但对于 todo_write + 可合并标题,使用替换避免堆叠重复
|
// Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates
|
||||||
let mergedContent = existing.content;
|
let mergedContent = existing.content;
|
||||||
if (updatedContent) {
|
if (updatedContent) {
|
||||||
if (
|
if (
|
||||||
@@ -181,7 +181,7 @@ export const useToolCalls = () => {
|
|||||||
(isTodoTitleMergeable(update.title) ||
|
(isTodoTitleMergeable(update.title) ||
|
||||||
isTodoTitleMergeable(existing.title))
|
isTodoTitleMergeable(existing.title))
|
||||||
) {
|
) {
|
||||||
mergedContent = updatedContent; // 覆盖
|
mergedContent = updatedContent; // Override
|
||||||
} else {
|
} else {
|
||||||
mergedContent = [...(existing.content || []), ...updatedContent];
|
mergedContent = [...(existing.content || []), ...updatedContent];
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ export const useToolCalls = () => {
|
|||||||
...(update.status && { status: update.status }),
|
...(update.status && { status: update.status }),
|
||||||
content: mergedContent,
|
content: mergedContent,
|
||||||
...(update.locations && { locations: update.locations }),
|
...(update.locations && { locations: update.locations }),
|
||||||
timestamp: nextTimestamp, // 更新时间戳(完成/失败时以完成时间为准)
|
timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newMap.set(update.toolCallId, {
|
newMap.set(update.toolCallId, {
|
||||||
@@ -211,7 +211,7 @@ export const useToolCalls = () => {
|
|||||||
rawInput: update.rawInput as string | object | undefined,
|
rawInput: update.rawInput as string | object | undefined,
|
||||||
content: updatedContent,
|
content: updatedContent,
|
||||||
locations: update.locations,
|
locations: update.locations,
|
||||||
timestamp: update.timestamp || Date.now(), // 添加时间戳
|
timestamp: update.timestamp || Date.now(), // Add timestamp
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export const useWebViewMessages = ({
|
|||||||
prevLines: string[],
|
prevLines: string[],
|
||||||
nextLines: string[],
|
nextLines: string[],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
// 认为“补充” = 旧内容的文本集合(忽略状态)被新内容包含
|
// Consider "supplement" = old content text collection (ignoring status) is contained in new content
|
||||||
const key = (line: string) => {
|
const key = (line: string) => {
|
||||||
const idx = line.indexOf('] ');
|
const idx = line.indexOf('] ');
|
||||||
return idx >= 0 ? line.slice(idx + 2).trim() : line.trim();
|
return idx >= 0 ? line.slice(idx + 2).trim() : line.trim();
|
||||||
@@ -350,12 +350,12 @@ export const useWebViewMessages = ({
|
|||||||
const entries = message.data.entries as PlanEntry[];
|
const entries = message.data.entries as PlanEntry[];
|
||||||
handlers.setPlanEntries(entries);
|
handlers.setPlanEntries(entries);
|
||||||
|
|
||||||
// 生成新的快照文本
|
// Generate new snapshot text
|
||||||
const lines = buildPlanLines(entries);
|
const lines = buildPlanLines(entries);
|
||||||
const text = lines.join('\n');
|
const text = lines.join('\n');
|
||||||
const prev = lastPlanSnapshotRef.current;
|
const prev = lastPlanSnapshotRef.current;
|
||||||
|
|
||||||
// 1) 完全相同 -> 跳过
|
// 1) Identical -> Skip
|
||||||
if (prev && prev.text === text) {
|
if (prev && prev.text === text) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -363,7 +363,7 @@ export const useWebViewMessages = ({
|
|||||||
try {
|
try {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
// 2) 补充或状态更新 -> 合并到上一条(使用 tool_call_update 覆盖内容)
|
// 2) Supplement or status update -> Merge to previous (use tool_call_update to override content)
|
||||||
if (prev && isSupplementOf(prev.lines, lines)) {
|
if (prev && isSupplementOf(prev.lines, lines)) {
|
||||||
handlers.handleToolCallUpdate({
|
handlers.handleToolCallUpdate({
|
||||||
type: 'tool_call_update',
|
type: 'tool_call_update',
|
||||||
@@ -381,7 +381,7 @@ export const useWebViewMessages = ({
|
|||||||
});
|
});
|
||||||
lastPlanSnapshotRef.current = { id: prev.id, text, lines };
|
lastPlanSnapshotRef.current = { id: prev.id, text, lines };
|
||||||
} else {
|
} else {
|
||||||
// 3) 其他情况 -> 新增一条历史卡片
|
// 3) Other cases -> Add a new history card
|
||||||
const toolCallId = `plan-snapshot-${ts}`;
|
const toolCallId = `plan-snapshot-${ts}`;
|
||||||
handlers.handleToolCallUpdate({
|
handlers.handleToolCallUpdate({
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
@@ -400,7 +400,7 @@ export const useWebViewMessages = ({
|
|||||||
lastPlanSnapshotRef.current = { id: toolCallId, text, lines };
|
lastPlanSnapshotRef.current = { id: toolCallId, text, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分割助手消息段,保持渲染块独立
|
// Split assistant message segments, keep rendering blocks independent
|
||||||
handlers.messageHandling.breakAssistantSegment?.();
|
handlers.messageHandling.breakAssistantSegment?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -30,7 +30,7 @@ export interface ToolCallUpdate {
|
|||||||
path: string;
|
path: string;
|
||||||
line?: number | null;
|
line?: number | null;
|
||||||
}>;
|
}>;
|
||||||
timestamp?: number; // 添加时间戳字段用于消息排序
|
timestamp?: number; // Add timestamp field for message ordering
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-env node */
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
// eslint-disable-next-line no-undef
|
export default {
|
||||||
module.exports = {
|
|
||||||
content: [
|
content: [
|
||||||
// 渐进式采用策略:只扫描新创建的Tailwind组件
|
// Progressive adoption strategy: Only scan newly created Tailwind components
|
||||||
'./src/webview/App.tsx',
|
'./src/webview/App.tsx',
|
||||||
'./src/webview/components/ui/**/*.{js,jsx,ts,tsx}',
|
'./src/webview/components/ui/**/*.{js,jsx,ts,tsx}',
|
||||||
'./src/webview/components/messages/**/*.{js,jsx,ts,tsx}',
|
'./src/webview/components/messages/**/*.{js,jsx,ts,tsx}',
|
||||||
@@ -13,9 +19,6 @@ module.exports = {
|
|||||||
'./src/webview/components/InputForm.tsx',
|
'./src/webview/components/InputForm.tsx',
|
||||||
'./src/webview/components/PermissionDrawer.tsx',
|
'./src/webview/components/PermissionDrawer.tsx',
|
||||||
'./src/webview/components/PlanDisplay.tsx',
|
'./src/webview/components/PlanDisplay.tsx',
|
||||||
// 当需要在更多组件中使用Tailwind时,可以逐步添加路径
|
|
||||||
// "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}",
|
|
||||||
// "./src/webview/pages/**/*.{js,jsx,ts,tsx}",
|
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html', 'clover'],
|
reporter: ['text', 'json', 'html', 'clover'],
|
||||||
|
|||||||
Reference in New Issue
Block a user