refactor(vscode): 重构消息排序和展示逻辑

- 移除旧的消息排序改进总结文档
- 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序
- 优化工具调用处理流程,添加时间戳支持
- 改进会话保存机制,直接使用SessionManager保存检查点
- 重构部分组件以提高可维护性
This commit is contained in:
yiliang114
2025-11-28 22:35:31 +08:00
parent 5ce40085d5
commit 9ae45c01a6
33 changed files with 299 additions and 837 deletions

57
package-lock.json generated
View File

@@ -3693,6 +3693,34 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"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": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
@@ -17143,34 +17171,6 @@
"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": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -17345,7 +17345,6 @@
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
"@qwen-code/qwen-code": "file:../cli",
"@qwen-code/qwen-code-core": "file:../core",
"cors": "^2.8.5",
"express": "^5.1.0",

View File

@@ -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 保持不变
- 现有功能不受影响
## 测试
创建了相关测试用例:
- 验证工具调用时间戳的正确添加和保留
- 验证消息排序逻辑的正确性
- 验证工具调用显示条件的正确性
## 验收标准达成情况
✅ 所有新添加的时间戳支持都已实现
✅ 消息按照时间顺序正确排列
✅ 现有功能不受影响
✅ 代码质量符合项目标准

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-undef */
module.exports = {
plugins: {

View File

@@ -135,7 +135,7 @@ export class AcpConnection {
}
/**
* 连接到ACP后端
* Connect to ACP backend
*
* @param backend - Backend type
* @param cliPath - CLI path

View File

@@ -39,6 +39,9 @@ export class QwenAgentManager {
private connectionHandler: QwenConnectionHandler;
private sessionUpdateHandler: QwenSessionUpdateHandler;
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
private callbacks: QwenAgentCallbacks = {};
@@ -92,6 +95,8 @@ export class QwenAgentManager {
_cliPath?: string,
): Promise<void> {
this.currentWorkingDir = workingDir;
// Remember the provided authStateManager for future calls
this.defaultAuthStateManager = authStateManager;
await this.connectionHandler.connect(
this.connection,
this.sessionReader,
@@ -392,23 +397,25 @@ export class QwenAgentManager {
'[QwenAgentManager] Current session ID (from CLI):',
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
// This ensures we save the complete session context including tool calls
if (this.currentSessionId) {
console.log(
'[QwenAgentManager] Using CLI /chat save command for complete save',
const tag = await this.sessionManager.saveCheckpoint(
qwenMessages,
conversationId,
this.currentWorkingDir,
this.currentSessionId || undefined,
);
return await this.saveCheckpointViaCommand(this.currentSessionId);
} else {
console.warn(
'[QwenAgentManager] No current session ID, cannot use /chat save',
);
return {
success: false,
message: 'No active CLI session',
};
}
return { success: true, tag };
} catch (error) {
console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED =====');
console.error('[QwenAgentManager] Error:', error);
@@ -634,12 +641,12 @@ export class QwenAgentManager {
const config = vscode.workspace.getConfiguration('qwenCode');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
if (authStateManager) {
hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
authMethod,
);
// Prefer the provided authStateManager, otherwise fall back to the one
// remembered during connect(). This prevents accidental re-auth in
// fallback paths (e.g. session switching) when the handler didn't pass it.
const effectiveAuth = authStateManager || this.defaultAuthStateManager;
if (effectiveAuth) {
hasValidAuth = await effectiveAuth.hasValidAuth(workingDir, authMethod);
console.log(
'[QwenAgentManager] Has valid cached auth for new session:',
hasValidAuth,
@@ -656,20 +663,20 @@ export class QwenAgentManager {
console.log('[QwenAgentManager] Authentication successful');
// Save auth state
if (authStateManager) {
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Saving auth state after successful authentication',
);
await authStateManager.saveAuthState(workingDir, authMethod);
await effectiveAuth.saveAuthState(workingDir, authMethod);
}
} catch (authError) {
console.error('[QwenAgentManager] Authentication failed:', authError);
// Clear potentially invalid cache
if (authStateManager) {
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Clearing auth cache due to authentication failure',
);
await authStateManager.clearAuthState();
await effectiveAuth.clearAuthState();
}
throw authError;
}

View File

@@ -59,7 +59,7 @@ export class QwenConnectionHandler {
`[QwenAgentManager] CLI version ${versionInfo.version} is below minimum required version ${'0.2.4'}`,
);
// TODO: 暂时注释
// TODO: Wait to determine release version number
// 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.`,
// );

View File

@@ -5,7 +5,7 @@
*/
import * as vscode from 'vscode';
import { CliDetector } from '../cli/cliDetector.js';
import { CliDetector } from './cliDetector.js';
/**
* CLI Detection and Installation Handler

View File

@@ -62,6 +62,8 @@ export const App: React.FC = () => {
} | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
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 [showBanner, setShowBanner] = useState(true);
const [editMode, setEditMode] = useState<EditMode>('ask');
@@ -173,6 +175,51 @@ export const App: React.FC = () => {
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
const handlePermissionResponse = useCallback(
(optionId: string) => {
@@ -380,6 +427,7 @@ export const App: React.FC = () => {
/>
<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]"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
@@ -387,23 +435,23 @@ export const App: React.FC = () => {
<EmptyState />
) : (
<>
{/* 创建统一的消息数组,包含所有类型的消息和工具调用 */}
{/* Create unified message array containing all types of messages and tool calls */}
{(() => {
// 普通消息
// Regular messages
const regularMessages = messageHandling.messages.map((msg) => ({
type: 'message' as const,
data: msg,
timestamp: msg.timestamp,
}));
// 进行中的工具调用
// In-progress tool calls
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
type: 'in-progress-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
// 完成的工具调用
// Completed tool calls
const completedTools = completedToolCalls
.filter(hasToolCallOutput)
.map((toolCall) => ({
@@ -412,7 +460,7 @@ export const App: React.FC = () => {
timestamp: toolCall.timestamp || Date.now(),
}));
// 合并并按时间戳排序,确保消息与工具调用穿插显示
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
const allMessages = [
...regularMessages,
...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.loadingMessage && (

View File

@@ -13,7 +13,7 @@ import { AuthStateManager } from '../auth/authStateManager.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.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';
export class WebViewProvider {

View File

@@ -22,7 +22,7 @@ interface PlanDisplayProps {
* PlanDisplay component - displays AI's task plan/todo list
*/
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
// 计算整体状态用于左侧圆点颜色
// Calculate overall status for left dot color
const allCompleted =
entries.length > 0 && entries.every((e) => e.status === 'completed');
const anyInProgress = entries.some((e) => e.status === 'in_progress');
@@ -36,14 +36,14 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
<div
className={[
'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)]',
// 左侧状态圆点,类似示例 .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]',
statusDotClass,
].join(' ')}
>
{/* 标题区域,类似示例中的 summary/_e/or */}
{/* Title area, similar to example summary/_e/or */}
<div className="plan-header w-full">
<div className="relative">
<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>
{/* 列表区域,类似示例中的 .qr/.Fr/.Hr */}
{/* List area, similar to example .qr/.Fr/.Hr */}
<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">
{entries.map((entry, index) => {
@@ -70,7 +70,7 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
isDone ? 'fo opacity-70' : '',
].join(' ')}
>
{/* 展示用复选框(复用组件) */}
{/* Display checkbox (reusable component) */}
<label className="flex items-start gap-2">
<CheckboxDisplay
checked={isDone}

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Save Session Dialog Styles */
.dialog-overlay {
position: fixed;

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Session Manager Styles */
.session-manager {
display: flex;

View File

@@ -18,18 +18,18 @@ export type TimelineItemType =
export interface TimelineItemProps {
type: TimelineItemType;
children: React.ReactNode;
/** 是否可折叠(主要用于工具输出) */
/** Whether collapsible (mainly for tool output) */
collapsible?: boolean;
/** 默认是否展开 */
/** Default expanded */
defaultExpanded?: boolean;
/** 自定义标题(用于折叠时显示) */
/** Custom title (used for display when collapsed) */
title?: string;
/** 是否是最后一个项目 */
/** Whether it is the last item */
isLast?: boolean;
}
/**
* Timeline 项目组件 - 统一展示消息和工具调用
* Timeline item component - Unified display of messages and tool calls
*/
export const TimelineItem: React.FC<TimelineItemProps> = ({
type,
@@ -44,15 +44,15 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
const getDotColor = (): string => {
switch (type) {
case 'user-message':
return 'blue'; // 用户消息 - 蓝色
return 'blue'; // User message - Blue
case 'assistant-message':
return 'gray'; // LLM 输出 - 灰色
return 'gray'; // LLM output - Gray
case 'tool-call':
return 'green'; // 工具调用 - 绿色
return 'green'; // Tool call - Green
case 'tool-output':
return 'gray'; // 工具输出 - 灰色
return 'gray'; // Tool output - Gray
case 'thinking':
return 'purple'; // 思考 - 紫色
return 'purple'; // Thinking - Purple
default:
return 'gray';
}
@@ -80,20 +80,20 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
return (
<div className={`timeline-item ${type} ${isLast ? 'last' : ''}`}>
{/* 时间线连接线 - 暂时禁用 */}
{/* Timeline connecting line - Temporarily disabled */}
{/* {!isLast && <div className="timeline-line" />} */}
{/* 状态圆点 */}
{/* Status dot */}
<div className={`timeline-dot ${dotColor}`}>
<span className="dot-inner" />
</div>
{/* 内容区域 */}
{/* Content area */}
<div className="timeline-content">
{/* 标签(可选) */}
{/* Label (optional) */}
{itemLabel && <div className="timeline-label">{itemLabel}</div>}
{/* 可折叠内容 */}
{/* Collapsible content */}
{collapsible ? (
<>
<button
@@ -119,7 +119,7 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
};
/**
* Timeline 容器组件
* Timeline container component
*/
export const Timeline: React.FC<{ children: React.ReactNode }> = ({
children,

View File

@@ -48,31 +48,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
{/* 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)]"

View File

@@ -32,7 +32,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
onFileClick,
status = 'default',
}) => {
// 空内容直接不渲染,避免只显示 ::before 的圆点导致观感不佳
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
if (!content || content.trim().length === 0) {
return null;
}

View File

@@ -4,6 +4,8 @@
* 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 { hasToolCallOutput } from '../toolcalls/shared/utils.js';

View File

@@ -20,8 +20,8 @@ interface SessionSelectorProps {
}
/**
* 会话选择器组件
* 显示会话列表并支持搜索和选择
* Session selector component
* Display session list and support search and selection
*/
export const SessionSelector: React.FC<SessionSelectorProps> = ({
visible,

View File

@@ -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 text = textOutputs.join('\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) {
for (const line of lines) {
const title = line.trim();
@@ -83,7 +83,7 @@ export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
const { content, status } = toolCall;
const { errors, textOutputs } = groupContent(content);
// 错误优先展示
// Error-first display
if (errors.length > 0) {
return (
<ToolCallContainer label="Update Todos" status="error">

View File

@@ -29,7 +29,7 @@ interface DiffDisplayProps {
oldText?: string | null;
newText?: string;
onOpenDiff?: () => void;
/** 是否显示统计信息 */
/** Whether to display statistics */
showStats?: boolean;
}
@@ -44,13 +44,13 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
onOpenDiff,
showStats = true,
}) => {
// 统计信息(仅在文本变化时重新计算)
// Statistics (recalculate only when text changes)
const stats = useMemo(
() => calculateDiffStats(oldText, newText),
[oldText, newText],
);
// 仅生成变更行(增加/删除),不渲染上下文
// Only generate changed lines (additions/deletions), do not render context
const ops: DiffOp[] = useMemo(
() => computeLineDiff(oldText, newText),
[oldText, newText],
@@ -106,7 +106,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
</div>
</div>
{/* 只绘制差异行的预览区域 */}
{/* Only draw preview area for diff lines */}
<pre className="diff-preview code-block" aria-label="Diff preview">
<div className="code-content">
{previewOps.length === 0 && (
@@ -142,7 +142,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
</div>
</pre>
{/* 在预览下方提供显式打开按钮(可选) */}
{/* Provide explicit open button below preview (optional) */}
{onOpenDiff && (
<div className="diff-compact-actions">
<button

View File

@@ -48,7 +48,7 @@ export interface ToolCallData {
rawInput?: string | object;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
timestamp?: number; // 添加时间戳字段用于消息排序
timestamp?: number; // Add a timestamp field for message sorting
}
/**

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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>
);

View File

@@ -25,6 +25,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
'getQwenSessions',
'saveSession',
'resumeSession',
// UI action: open a new chat tab (new WebviewPanel)
'openNewChatTab',
].includes(messageType);
}
@@ -82,6 +84,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
await this.handleResumeSession((data?.sessionId as string) || '');
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:
console.warn(
'[SessionMessageHandler] Unknown message type:',

View File

@@ -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);
});
});

View File

@@ -5,7 +5,7 @@
*/
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';
interface CompletionTriggerState {
@@ -27,6 +27,26 @@ export function useCompletionTrigger(
query: string,
) => 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>({
isOpen: false,
triggerChar: null,
@@ -35,7 +55,15 @@ export function useCompletionTrigger(
items: [],
});
// Timer for loading timeout
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeCompletion = useCallback(() => {
// Clear pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setState({
isOpen: false,
triggerChar: null,
@@ -51,16 +79,56 @@ export function useCompletionTrigger(
query: string,
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({
isOpen: true,
triggerChar: trigger,
query,
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 () => {

View File

@@ -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);
});
});

View File

@@ -108,11 +108,11 @@ export const useToolCalls = () => {
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)) {
const nextText = extractText(content);
// 找最近一条 todo_write + 可合并标题 的卡片
// Find the most recent card with todo_write + mergeable title
let lastId: string | null = null;
let lastText = '';
let lastTimestamp = 0;
@@ -132,16 +132,16 @@ export const useToolCalls = () => {
if (lastId) {
const cmp = isSameOrSupplement(lastText, nextText);
if (cmp.same) {
// 完全相同:忽略本次新增
// Completely identical: Ignore this addition
return newMap;
}
if (cmp.supplement) {
// 补充:替换内容到上一条(使用更新语义)
// Supplement: Replace content to the previous item (using update semantics)
const prev = newMap.get(lastId);
if (prev) {
newMap.set(lastId, {
...prev,
content, // 覆盖(不追加)
content, // Override (do not append)
status: update.status || prev.status,
timestamp: update.timestamp || Date.now(),
});
@@ -159,7 +159,7 @@ export const useToolCalls = () => {
rawInput: update.rawInput as string | object | undefined,
content,
locations: update.locations,
timestamp: update.timestamp || Date.now(), // 添加时间戳
timestamp: update.timestamp || Date.now(), // Add timestamp
});
} else if (update.type === 'tool_call_update') {
const updatedContent = update.content
@@ -173,7 +173,7 @@ export const useToolCalls = () => {
: undefined;
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;
if (updatedContent) {
if (
@@ -181,7 +181,7 @@ export const useToolCalls = () => {
(isTodoTitleMergeable(update.title) ||
isTodoTitleMergeable(existing.title))
) {
mergedContent = updatedContent; // 覆盖
mergedContent = updatedContent; // Override
} else {
mergedContent = [...(existing.content || []), ...updatedContent];
}
@@ -200,7 +200,7 @@ export const useToolCalls = () => {
...(update.status && { status: update.status }),
content: mergedContent,
...(update.locations && { locations: update.locations }),
timestamp: nextTimestamp, // 更新时间戳(完成/失败时以完成时间为准)
timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed)
});
} else {
newMap.set(update.toolCallId, {
@@ -211,7 +211,7 @@ export const useToolCalls = () => {
rawInput: update.rawInput as string | object | undefined,
content: updatedContent,
locations: update.locations,
timestamp: update.timestamp || Date.now(), // 添加时间戳
timestamp: update.timestamp || Date.now(), // Add timestamp
});
}
}

View File

@@ -137,7 +137,7 @@ export const useWebViewMessages = ({
prevLines: string[],
nextLines: string[],
): boolean => {
// 认为“补充” = 旧内容的文本集合(忽略状态)被新内容包含
// Consider "supplement" = old content text collection (ignoring status) is contained in new content
const key = (line: string) => {
const idx = line.indexOf('] ');
return idx >= 0 ? line.slice(idx + 2).trim() : line.trim();
@@ -350,12 +350,12 @@ export const useWebViewMessages = ({
const entries = message.data.entries as PlanEntry[];
handlers.setPlanEntries(entries);
// 生成新的快照文本
// Generate new snapshot text
const lines = buildPlanLines(entries);
const text = lines.join('\n');
const prev = lastPlanSnapshotRef.current;
// 1) 完全相同 -> 跳过
// 1) Identical -> Skip
if (prev && prev.text === text) {
break;
}
@@ -363,7 +363,7 @@ export const useWebViewMessages = ({
try {
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)) {
handlers.handleToolCallUpdate({
type: 'tool_call_update',
@@ -381,7 +381,7 @@ export const useWebViewMessages = ({
});
lastPlanSnapshotRef.current = { id: prev.id, text, lines };
} else {
// 3) 其他情况 -> 新增一条历史卡片
// 3) Other cases -> Add a new history card
const toolCallId = `plan-snapshot-${ts}`;
handlers.handleToolCallUpdate({
type: 'tool_call',
@@ -400,7 +400,7 @@ export const useWebViewMessages = ({
lastPlanSnapshotRef.current = { id: toolCallId, text, lines };
}
// 分割助手消息段,保持渲染块独立
// Split assistant message segments, keep rendering blocks independent
handlers.messageHandling.breakAssistantSegment?.();
} catch (err) {
console.warn(

View File

@@ -1,3 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -30,7 +30,7 @@ export interface ToolCallUpdate {
path: string;
line?: number | null;
}>;
timestamp?: number; // 添加时间戳字段用于消息排序
timestamp?: number; // Add timestamp field for message ordering
}
/**

View File

@@ -1,8 +1,14 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-env node */
/** @type {import('tailwindcss').Config} */
// eslint-disable-next-line no-undef
module.exports = {
export default {
content: [
// 渐进式采用策略只扫描新创建的Tailwind组件
// Progressive adoption strategy: Only scan newly created Tailwind components
'./src/webview/App.tsx',
'./src/webview/components/ui/**/*.{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/PermissionDrawer.tsx',
'./src/webview/components/PlanDisplay.tsx',
// 当需要在更多组件中使用Tailwind时可以逐步添加路径
// "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}",
// "./src/webview/pages/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {

View File

@@ -3,8 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'clover'],