mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion): 改进消息排序和显示逻辑
- 添加时间戳支持,确保消息按时间顺序排列 - 更新工具调用处理逻辑,自动添加和保留时间戳 - 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染 - 优化完成的工具调用显示,修复显示顺序问题 - 调整进行中的工具调用显示,统一到消息流中展示 - 移除重复的计划展示逻辑,避免最新块重复出现 - 重构消息处理和渲染代码,提高可维护性
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# 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 保持不变
|
||||||
|
- 现有功能不受影响
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
创建了相关测试用例:
|
||||||
|
|
||||||
|
- 验证工具调用时间戳的正确添加和保留
|
||||||
|
- 验证消息排序逻辑的正确性
|
||||||
|
- 验证工具调用显示条件的正确性
|
||||||
|
|
||||||
|
## 验收标准达成情况
|
||||||
|
|
||||||
|
✅ 所有新添加的时间戳支持都已实现
|
||||||
|
✅ 消息按照时间顺序正确排列
|
||||||
|
✅ 现有功能不受影响
|
||||||
|
✅ 代码质量符合项目标准
|
||||||
@@ -44,7 +44,34 @@ const cssInjectPlugin = {
|
|||||||
const tailwindcss = (await import('tailwindcss')).default;
|
const tailwindcss = (await import('tailwindcss')).default;
|
||||||
const autoprefixer = (await import('autoprefixer')).default;
|
const autoprefixer = (await import('autoprefixer')).default;
|
||||||
|
|
||||||
const css = await fs.promises.readFile(args.path, 'utf8');
|
let css = await fs.promises.readFile(args.path, 'utf8');
|
||||||
|
|
||||||
|
// For ClaudeCodeStyles.css, we need to resolve @import statements
|
||||||
|
if (args.path.endsWith('ClaudeCodeStyles.css')) {
|
||||||
|
// Read all imported CSS files and inline them
|
||||||
|
const importRegex = /@import\s+'([^']+)';/g;
|
||||||
|
let match;
|
||||||
|
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
|
||||||
|
while ((match = importRegex.exec(css)) !== null) {
|
||||||
|
const importPath = match[1];
|
||||||
|
// Resolve relative paths correctly
|
||||||
|
let fullPath;
|
||||||
|
if (importPath.startsWith('./')) {
|
||||||
|
fullPath = basePath + importPath.substring(1);
|
||||||
|
} else if (importPath.startsWith('../')) {
|
||||||
|
fullPath = basePath + '/' + importPath;
|
||||||
|
} else {
|
||||||
|
fullPath = basePath + '/' + importPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
|
||||||
|
css = css.replace(match[0], importedCss);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Could not import ${fullPath}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process with PostCSS (Tailwind + Autoprefixer)
|
// Process with PostCSS (Tailwind + Autoprefixer)
|
||||||
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
||||||
@@ -65,10 +92,25 @@ const cssInjectPlugin = {
|
|||||||
// Handle SCSS files
|
// Handle SCSS files
|
||||||
build.onLoad({ filter: /\.scss$/ }, async (args) => {
|
build.onLoad({ filter: /\.scss$/ }, async (args) => {
|
||||||
const sass = await import('sass');
|
const sass = await import('sass');
|
||||||
const result = sass.compile(args.path, {
|
const postcss = (await import('postcss')).default;
|
||||||
|
const tailwindcss = (await import('tailwindcss')).default;
|
||||||
|
const autoprefixer = (await import('autoprefixer')).default;
|
||||||
|
|
||||||
|
// Compile SCSS to CSS
|
||||||
|
const sassResult = sass.compile(args.path, {
|
||||||
loadPaths: [args.path.substring(0, args.path.lastIndexOf('/'))],
|
loadPaths: [args.path.substring(0, args.path.lastIndexOf('/'))],
|
||||||
});
|
});
|
||||||
const css = result.css;
|
|
||||||
|
// Process with PostCSS (Tailwind + Autoprefixer)
|
||||||
|
const postcssResult = await postcss([tailwindcss, autoprefixer]).process(
|
||||||
|
sassResult.css,
|
||||||
|
{
|
||||||
|
from: args.path,
|
||||||
|
to: args.path,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const css = postcssResult.css;
|
||||||
return {
|
return {
|
||||||
contents: `
|
contents: `
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
|
|||||||
@@ -66,7 +66,16 @@ export class QwenAgentManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.connection.onEndTurn = () => {
|
this.connection.onEndTurn = () => {
|
||||||
// Notify UI response complete
|
try {
|
||||||
|
if (this.callbacks.onEndTurn) {
|
||||||
|
this.callbacks.onEndTurn();
|
||||||
|
} else if (this.callbacks.onStreamChunk) {
|
||||||
|
// Fallback: send a zero-length chunk then rely on streamEnd elsewhere
|
||||||
|
this.callbacks.onStreamChunk('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[QwenAgentManager] onEndTurn callback error:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ export class QwenAgentManager {
|
|||||||
async connect(
|
async connect(
|
||||||
workingDir: string,
|
workingDir: string,
|
||||||
authStateManager?: AuthStateManager,
|
authStateManager?: AuthStateManager,
|
||||||
|
_cliPath?: string, // TODO: reserved for future override via settings
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.currentWorkingDir = workingDir;
|
this.currentWorkingDir = workingDir;
|
||||||
await this.connectionHandler.connect(
|
await this.connectionHandler.connect(
|
||||||
@@ -756,6 +766,16 @@ export class QwenAgentManager {
|
|||||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register end-of-turn callback
|
||||||
|
*
|
||||||
|
* @param callback - Called when ACP stopReason === 'end_turn'
|
||||||
|
*/
|
||||||
|
onEndTurn(callback: () => void): void {
|
||||||
|
this.callbacks.onEndTurn = callback;
|
||||||
|
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect
|
* Disconnect
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -65,4 +65,6 @@ export interface QwenAgentCallbacks {
|
|||||||
onPlan?: (entries: PlanEntry[]) => void;
|
onPlan?: (entries: PlanEntry[]) => void;
|
||||||
/** Permission request callback */
|
/** Permission request callback */
|
||||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
||||||
|
/** End of turn callback (e.g., stopReason === 'end_turn') */
|
||||||
|
onEndTurn?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure tool call containers keep a consistent left indent even if Tailwind utilities are purged */
|
||||||
|
.toolcall-container {
|
||||||
|
/* Consistent indent for tool call blocks */
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolcall-card {
|
||||||
|
/* Consistent indent for card-style tool calls */
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
color: var(--app-primary-foreground);
|
color: var(--app-primary-foreground);
|
||||||
font-family: var(--vscode-chat-font-family);
|
font-family: var(--vscode-chat-font-family);
|
||||||
@@ -233,92 +244,16 @@ button {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================
|
/* Animation for in-progress status (used by pseudo bullets and spinners) */
|
||||||
In-Progress Tool Call Styles (Claude Code style)
|
|
||||||
=========================== */
|
|
||||||
.in-progress-tool-call {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--app-spacing-small);
|
|
||||||
padding: var(--app-spacing-medium);
|
|
||||||
margin: var(--app-spacing-small) 0;
|
|
||||||
background: var(--app-input-background);
|
|
||||||
border: 1px solid var(--app-input-border);
|
|
||||||
border-radius: var(--corner-radius-small);
|
|
||||||
animation: fadeIn 0.2s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--app-spacing-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-kind {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--app-primary-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--app-secondary-foreground);
|
|
||||||
position: relative;
|
|
||||||
padding-left: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status.pending::before {
|
|
||||||
background: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status.in_progress::before {
|
|
||||||
background: #2196f3;
|
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status.completed::before {
|
|
||||||
background: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-status.failed::before {
|
|
||||||
background: #f44336;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.4;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.in-progress-tool-call-title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--app-secondary-foreground);
|
|
||||||
font-family: var(--app-monospace-font-family);
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-progress-tool-call-locations {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
font-family: var(--app-monospace-font-family);
|
font-family: var(--app-monospace-font-family);
|
||||||
font-size: var(--app-monospace-font-size);
|
font-size: var(--app-monospace-font-size);
|
||||||
@@ -658,4 +593,3 @@ button {
|
|||||||
color: #4caf50;
|
color: #4caf50;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useVSCode } from './hooks/useVSCode.js';
|
import { useVSCode } from './hooks/useVSCode.js';
|
||||||
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
|
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
|
||||||
import { useFileContext } from './hooks/file/useFileContext.js';
|
import { useFileContext } from './hooks/file/useFileContext.js';
|
||||||
@@ -16,16 +16,15 @@ import type {
|
|||||||
PermissionOption,
|
PermissionOption,
|
||||||
ToolCall as PermissionToolCall,
|
ToolCall as PermissionToolCall,
|
||||||
} from './components/PermissionRequest.js';
|
} from './components/PermissionRequest.js';
|
||||||
|
import type { TextMessage } from './hooks/message/useMessageHandling.js';
|
||||||
|
import type { ToolCallData } from './components/ToolCall.js';
|
||||||
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
||||||
import { ToolCall } from './components/ToolCall.js';
|
import { ToolCall } from './components/ToolCall.js';
|
||||||
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
|
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
|
||||||
import { InProgressToolCall } from './components/InProgressToolCall.js';
|
import { InProgressToolCall } from './components/InProgressToolCall.js';
|
||||||
import { EmptyState } from './components/EmptyState.js';
|
import { EmptyState } from './components/EmptyState.js';
|
||||||
import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js';
|
import type { PlanEntry } from './components/PlanDisplay.js';
|
||||||
import {
|
import { type CompletionItem } from './components/CompletionMenu.js';
|
||||||
CompletionMenu,
|
|
||||||
type CompletionItem,
|
|
||||||
} from './components/CompletionMenu.js';
|
|
||||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||||
import { SaveSessionDialog } from './components/SaveSessionDialog.js';
|
import { SaveSessionDialog } from './components/SaveSessionDialog.js';
|
||||||
import { InfoBanner } from './components/InfoBanner.js';
|
import { InfoBanner } from './components/InfoBanner.js';
|
||||||
@@ -34,7 +33,6 @@ import {
|
|||||||
UserMessage,
|
UserMessage,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ThinkingMessage,
|
ThinkingMessage,
|
||||||
StreamingMessage,
|
|
||||||
WaitingMessage,
|
WaitingMessage,
|
||||||
} from './components/messages/index.js';
|
} from './components/messages/index.js';
|
||||||
import { InputForm } from './components/InputForm.js';
|
import { InputForm } from './components/InputForm.js';
|
||||||
@@ -87,7 +85,9 @@ export const App: React.FC = () => {
|
|||||||
description: file.description,
|
description: file.description,
|
||||||
type: 'file' as const,
|
type: 'file' as const,
|
||||||
icon: fileIcon,
|
icon: fileIcon,
|
||||||
value: file.path,
|
// Insert filename after @, keep path for mapping
|
||||||
|
value: file.label,
|
||||||
|
path: file.path,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,8 +102,21 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If first time and still loading, show a placeholder
|
||||||
|
if (allItems.length === 0) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'loading-files',
|
||||||
|
label: 'Searching files…',
|
||||||
|
description: 'Type to filter, or wait a moment…',
|
||||||
|
type: 'info' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return allItems;
|
return allItems;
|
||||||
} else {
|
} else {
|
||||||
|
// Handle slash commands
|
||||||
const commands: CompletionItem[] = [
|
const commands: CompletionItem[] = [
|
||||||
{
|
{
|
||||||
id: 'login',
|
id: 'login',
|
||||||
@@ -124,18 +137,25 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
||||||
|
|
||||||
|
// When workspace files update while menu open for @, refresh items so the first @ shows the list
|
||||||
|
useEffect(() => {
|
||||||
|
if (completion.isOpen && completion.triggerChar === '@') {
|
||||||
|
completion.refreshCompletion();
|
||||||
|
}
|
||||||
|
}, [fileContext.workspaceFiles, completion]);
|
||||||
|
|
||||||
// Message submission
|
// Message submission
|
||||||
const { handleSubmit } = useMessageSubmit({
|
const handleSubmit = useMessageSubmit({
|
||||||
vscode,
|
|
||||||
inputText,
|
inputText,
|
||||||
setInputText,
|
setInputText,
|
||||||
|
messageHandling,
|
||||||
|
fileContext,
|
||||||
|
vscode,
|
||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
isStreaming: messageHandling.isStreaming,
|
isStreaming: messageHandling.isStreaming,
|
||||||
fileContext,
|
|
||||||
messageHandling,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebView messages
|
// Message handling
|
||||||
useWebViewMessages({
|
useWebViewMessages({
|
||||||
sessionManagement,
|
sessionManagement,
|
||||||
fileContext,
|
fileContext,
|
||||||
@@ -143,22 +163,16 @@ export const App: React.FC = () => {
|
|||||||
handleToolCallUpdate,
|
handleToolCallUpdate,
|
||||||
clearToolCalls,
|
clearToolCalls,
|
||||||
setPlanEntries,
|
setPlanEntries,
|
||||||
handlePermissionRequest: React.useCallback(
|
handlePermissionRequest: setPermissionRequest,
|
||||||
(request: {
|
|
||||||
options: PermissionOption[];
|
|
||||||
toolCall: PermissionToolCall;
|
|
||||||
}) => {
|
|
||||||
setPermissionRequest(request);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
inputFieldRef,
|
inputFieldRef,
|
||||||
setInputText,
|
setInputText,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Permission handling
|
// Handle permission response
|
||||||
const handlePermissionResponse = React.useCallback(
|
const handlePermissionResponse = useCallback(
|
||||||
(optionId: string) => {
|
(optionId: string) => {
|
||||||
|
// Forward the selected optionId directly to extension as ACP permission response
|
||||||
|
// Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc.
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'permissionResponse',
|
type: 'permissionResponse',
|
||||||
data: { optionId },
|
data: { optionId },
|
||||||
@@ -168,182 +182,153 @@ export const App: React.FC = () => {
|
|||||||
[vscode],
|
[vscode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Completion selection
|
// Handle completion selection
|
||||||
const handleCompletionSelect = React.useCallback(
|
const handleCompletionSelect = useCallback(
|
||||||
(item: CompletionItem) => {
|
(item: CompletionItem) => {
|
||||||
if (!inputFieldRef.current) {
|
// Handle completion selection by inserting the value into the input field
|
||||||
|
const inputElement = inputFieldRef.current;
|
||||||
|
if (!inputElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputElement = inputFieldRef.current;
|
// Ignore info items (placeholders like "Searching files…")
|
||||||
const currentText = inputElement.textContent || '';
|
if (item.type === 'info') {
|
||||||
|
completion.closeCompletion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash commands can execute immediately
|
||||||
if (item.type === 'command') {
|
if (item.type === 'command') {
|
||||||
if (item.label === '/login') {
|
const command = (item.label || '').trim();
|
||||||
inputElement.textContent = '';
|
if (command === '/login') {
|
||||||
setInputText('');
|
vscode.postMessage({ type: 'login', data: {} });
|
||||||
completion.closeCompletion();
|
completion.closeCompletion();
|
||||||
vscode.postMessage({
|
|
||||||
type: 'login',
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inputElement.textContent = item.label + ' ';
|
|
||||||
setInputText(item.label + ' ');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (inputElement.firstChild) {
|
|
||||||
range.setStart(inputElement.firstChild, (item.label + ' ').length);
|
|
||||||
range.collapse(true);
|
|
||||||
} else {
|
|
||||||
range.selectNodeContents(inputElement);
|
|
||||||
range.collapse(false);
|
|
||||||
}
|
|
||||||
sel?.removeAllRanges();
|
|
||||||
sel?.addRange(range);
|
|
||||||
inputElement.focus();
|
|
||||||
}, 10);
|
|
||||||
} else if (item.type === 'file') {
|
|
||||||
const filePath = (item.value as string) || item.label;
|
|
||||||
fileContext.addFileReference(item.label, filePath);
|
|
||||||
|
|
||||||
const atPos = currentText.lastIndexOf('@');
|
|
||||||
|
|
||||||
if (atPos !== -1) {
|
|
||||||
const textAfterAt = currentText.substring(atPos + 1);
|
|
||||||
const spaceIndex = textAfterAt.search(/[\s\n]/);
|
|
||||||
const queryEnd =
|
|
||||||
spaceIndex === -1 ? currentText.length : atPos + 1 + spaceIndex;
|
|
||||||
|
|
||||||
const textBefore = currentText.substring(0, atPos);
|
|
||||||
const textAfter = currentText.substring(queryEnd);
|
|
||||||
const newText = `${textBefore}@${item.label} ${textAfter}`;
|
|
||||||
|
|
||||||
inputElement.textContent = newText;
|
|
||||||
setInputText(newText);
|
|
||||||
|
|
||||||
const newCursorPos = atPos + item.label.length + 2;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const textNode = inputElement.firstChild;
|
|
||||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection) {
|
|
||||||
const range = document.createRange();
|
|
||||||
try {
|
|
||||||
range.setStart(
|
|
||||||
textNode,
|
|
||||||
Math.min(newCursorPos, newText.length),
|
|
||||||
);
|
|
||||||
range.collapse(true);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[handleCompletionSelect] Error:', e);
|
|
||||||
range.selectNodeContents(inputElement);
|
|
||||||
range.collapse(false);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inputElement.focus();
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If selecting a file, add @filename -> fullpath mapping
|
||||||
|
if (item.type === 'file' && item.value && item.path) {
|
||||||
|
try {
|
||||||
|
fileContext.addFileReference(item.value, item.path);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[App] addFileReference failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current text and cursor
|
||||||
|
const text = inputElement.textContent || '';
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
|
||||||
|
// Compute total text offset for contentEditable
|
||||||
|
let cursorPos = text.length;
|
||||||
|
if (range.startContainer === inputElement) {
|
||||||
|
const childIndex = range.startOffset;
|
||||||
|
let offset = 0;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < childIndex && i < inputElement.childNodes.length;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
offset += inputElement.childNodes[i].textContent?.length || 0;
|
||||||
|
}
|
||||||
|
cursorPos = offset || text.length;
|
||||||
|
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
inputElement,
|
||||||
|
NodeFilter.SHOW_TEXT,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
let offset = 0;
|
||||||
|
let found = false;
|
||||||
|
let node: Node | null = walker.nextNode();
|
||||||
|
while (node) {
|
||||||
|
if (node === range.startContainer) {
|
||||||
|
offset += range.startOffset;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += node.textContent?.length || 0;
|
||||||
|
node = walker.nextNode();
|
||||||
|
}
|
||||||
|
cursorPos = found ? offset : text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace from trigger to cursor with selected value
|
||||||
|
const textBeforeCursor = text.substring(0, cursorPos);
|
||||||
|
const atPos = textBeforeCursor.lastIndexOf('@');
|
||||||
|
const slashPos = textBeforeCursor.lastIndexOf('/');
|
||||||
|
const triggerPos = Math.max(atPos, slashPos);
|
||||||
|
|
||||||
|
if (triggerPos >= 0) {
|
||||||
|
const insertValue =
|
||||||
|
typeof item.value === 'string' ? item.value : String(item.label);
|
||||||
|
const newText =
|
||||||
|
text.substring(0, triggerPos + 1) + // keep the trigger symbol
|
||||||
|
insertValue +
|
||||||
|
' ' +
|
||||||
|
text.substring(cursorPos);
|
||||||
|
|
||||||
|
// Update DOM and state, and move caret to end
|
||||||
|
inputElement.textContent = newText;
|
||||||
|
setInputText(newText);
|
||||||
|
|
||||||
|
const newRange = document.createRange();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
newRange.selectNodeContents(inputElement);
|
||||||
|
newRange.collapse(false);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(newRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the completion menu
|
||||||
completion.closeCompletion();
|
completion.closeCompletion();
|
||||||
},
|
},
|
||||||
[completion, vscode, fileContext],
|
[completion, inputFieldRef, setInputText, fileContext, vscode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attach context (Cmd/Ctrl + /)
|
// Handle save session
|
||||||
const handleAttachContextClick = React.useCallback(async () => {
|
const handleSaveSession = useCallback(
|
||||||
if (inputFieldRef.current) {
|
async (tag: string) => {
|
||||||
inputFieldRef.current.focus();
|
if (!sessionManagement.currentSessionId) {
|
||||||
|
return;
|
||||||
const currentText = inputFieldRef.current.textContent || '';
|
|
||||||
const newText = currentText ? `${currentText} @` : '@';
|
|
||||||
inputFieldRef.current.textContent = newText;
|
|
||||||
setInputText(newText);
|
|
||||||
|
|
||||||
const range = document.createRange();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
range.selectNodeContents(inputFieldRef.current);
|
|
||||||
range.collapse(false);
|
|
||||||
sel?.removeAllRanges();
|
|
||||||
sel?.addRange(range);
|
|
||||||
|
|
||||||
requestAnimationFrame(async () => {
|
|
||||||
if (!inputFieldRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let position = { top: 0, left: 0 };
|
|
||||||
const selection = window.getSelection();
|
|
||||||
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
|
||||||
try {
|
|
||||||
const currentRange = selection.getRangeAt(0);
|
|
||||||
const rangeRect = currentRange.getBoundingClientRect();
|
|
||||||
if (rangeRect.top > 0 && rangeRect.left > 0) {
|
|
||||||
position = {
|
|
||||||
top: rangeRect.top,
|
|
||||||
left: rangeRect.left,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
|
||||||
position = { top: inputRect.top, left: inputRect.left };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[App] Error getting cursor position:', error);
|
|
||||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
|
||||||
position = { top: inputRect.top, left: inputRect.left };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
|
||||||
position = { top: inputRect.top, left: inputRect.left };
|
|
||||||
}
|
|
||||||
|
|
||||||
await completion.openCompletion('@', '', position);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [completion]);
|
|
||||||
|
|
||||||
// Keyboard shortcut for attach context
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAttachContextClick();
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
try {
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
vscode.postMessage({
|
||||||
}, [handleAttachContextClick]);
|
type: 'saveSession',
|
||||||
|
data: {
|
||||||
|
sessionId: sessionManagement.currentSessionId,
|
||||||
|
tag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-scroll to latest message
|
// Assume success for now, as we don't get a response
|
||||||
useEffect(() => {
|
sessionManagement.setSavedSessionTags((prev) => [...prev, tag]);
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
setShowSaveDialog(false);
|
||||||
}, [messageHandling.messages, messageHandling.currentStreamContent]);
|
} catch (error) {
|
||||||
|
console.error('[App] Error saving session:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sessionManagement, vscode],
|
||||||
|
);
|
||||||
|
|
||||||
// Load sessions on mount
|
// Handle attach context click
|
||||||
useEffect(() => {
|
const handleAttachContextClick = useCallback(() => {
|
||||||
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
// Open native file picker (different from '@' completion which searches workspace files)
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'attachFile',
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
}, [vscode]);
|
}, [vscode]);
|
||||||
|
|
||||||
// Request active editor on mount
|
// Handle toggle edit mode
|
||||||
useEffect(() => {
|
const handleToggleEditMode = useCallback(() => {
|
||||||
fileContext.requestActiveEditor();
|
|
||||||
}, [fileContext]);
|
|
||||||
|
|
||||||
// Toggle edit mode
|
|
||||||
const handleToggleEditMode = () => {
|
|
||||||
setEditMode((prev) => {
|
setEditMode((prev) => {
|
||||||
if (prev === 'ask') {
|
if (prev === 'ask') {
|
||||||
return 'auto';
|
return 'auto';
|
||||||
@@ -353,8 +338,9 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return 'ask';
|
return 'ask';
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Handle toggle thinking
|
||||||
const handleToggleThinking = () => {
|
const handleToggleThinking = () => {
|
||||||
setThinkingEnabled((prev) => !prev);
|
setThinkingEnabled((prev) => !prev);
|
||||||
};
|
};
|
||||||
@@ -389,66 +375,119 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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 [&>*]: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)' }}
|
||||||
>
|
>
|
||||||
{!hasContent ? (
|
{!hasContent ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{messageHandling.messages.map((msg, index) => {
|
{/* 创建统一的消息数组,包含所有类型的消息和工具调用 */}
|
||||||
const handleFileClick = (path: string) => {
|
{(() => {
|
||||||
vscode.postMessage({
|
// 普通消息
|
||||||
type: 'openFile',
|
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||||
data: { path },
|
type: 'message' as const,
|
||||||
});
|
data: msg,
|
||||||
};
|
timestamp: msg.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
if (msg.role === 'thinking') {
|
// 进行中的工具调用
|
||||||
return (
|
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||||
<ThinkingMessage
|
type: 'in-progress-tool-call' as const,
|
||||||
key={index}
|
data: toolCall,
|
||||||
content={msg.content}
|
timestamp: toolCall.timestamp || Date.now(),
|
||||||
timestamp={msg.timestamp}
|
}));
|
||||||
onFileClick={handleFileClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === 'user') {
|
// 完成的工具调用
|
||||||
return (
|
const completedTools = completedToolCalls
|
||||||
<UserMessage
|
.filter(hasToolCallOutput)
|
||||||
key={index}
|
.map((toolCall) => ({
|
||||||
content={msg.content}
|
type: 'completed-tool-call' as const,
|
||||||
timestamp={msg.timestamp}
|
data: toolCall,
|
||||||
onFileClick={handleFileClick}
|
timestamp: toolCall.timestamp || Date.now(),
|
||||||
fileContext={msg.fileContext}
|
}));
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
// 合并并按时间戳排序,确保消息与工具调用穿插显示
|
||||||
<AssistantMessage
|
const allMessages = [
|
||||||
key={index}
|
...regularMessages,
|
||||||
content={msg.content}
|
...inProgressTools,
|
||||||
timestamp={msg.timestamp}
|
...completedTools,
|
||||||
onFileClick={handleFileClick}
|
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{inProgressToolCalls.map((toolCall) => (
|
console.log('[App] allMessages:', allMessages);
|
||||||
<InProgressToolCall
|
|
||||||
key={toolCall.toolCallId}
|
|
||||||
toolCall={toolCall}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{completedToolCalls.filter(hasToolCallOutput).map((toolCall) => (
|
return allMessages.map((item, index) => {
|
||||||
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
|
switch (item.type) {
|
||||||
))}
|
case 'message': {
|
||||||
|
const msg = item.data as TextMessage;
|
||||||
|
const handleFileClick = (path: string) => {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'openFile',
|
||||||
|
data: { path },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
{planEntries.length > 0 && <PlanDisplay entries={planEntries} />}
|
if (msg.role === 'thinking') {
|
||||||
|
return (
|
||||||
|
<div key={`message-${index}`} className="message-item">
|
||||||
|
<ThinkingMessage
|
||||||
|
content={msg.content || ''}
|
||||||
|
timestamp={msg.timestamp || 0}
|
||||||
|
onFileClick={handleFileClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
return (
|
||||||
|
<div key={`message-${index}`} className="message-item">
|
||||||
|
<UserMessage
|
||||||
|
content={msg.content || ''}
|
||||||
|
timestamp={msg.timestamp || 0}
|
||||||
|
onFileClick={handleFileClick}
|
||||||
|
fileContext={msg.fileContext}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`message-${index}`} className="message-item">
|
||||||
|
<AssistantMessage
|
||||||
|
content={msg.content || ''}
|
||||||
|
timestamp={msg.timestamp || 0}
|
||||||
|
onFileClick={handleFileClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'in-progress-tool-call':
|
||||||
|
return (
|
||||||
|
<InProgressToolCall
|
||||||
|
key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
|
||||||
|
toolCall={item.data as ToolCallData}
|
||||||
|
// onFileClick={handleFileClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'completed-tool-call':
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
||||||
|
toolCall={item.data as ToolCallData}
|
||||||
|
// onFileClick={handleFileClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 已改为在 useWebViewMessages 中将每次 plan 推送为历史 toolcall,避免重复展示最新块 */}
|
||||||
|
|
||||||
{messageHandling.isWaitingForResponse &&
|
{messageHandling.isWaitingForResponse &&
|
||||||
messageHandling.loadingMessage && (
|
messageHandling.loadingMessage && (
|
||||||
@@ -457,19 +496,6 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messageHandling.isStreaming &&
|
|
||||||
messageHandling.currentStreamContent && (
|
|
||||||
<StreamingMessage
|
|
||||||
content={messageHandling.currentStreamContent}
|
|
||||||
onFileClick={(path) => {
|
|
||||||
vscode.postMessage({
|
|
||||||
type: 'openFile',
|
|
||||||
data: { path },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -497,7 +523,7 @@ export const App: React.FC = () => {
|
|||||||
onCompositionStart={() => setIsComposing(true)}
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
onCompositionEnd={() => setIsComposing(false)}
|
onCompositionEnd={() => setIsComposing(false)}
|
||||||
onKeyDown={() => {}}
|
onKeyDown={() => {}}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit.handleSubmit}
|
||||||
onToggleEditMode={handleToggleEditMode}
|
onToggleEditMode={handleToggleEditMode}
|
||||||
onToggleThinking={handleToggleThinking}
|
onToggleThinking={handleToggleThinking}
|
||||||
onFocusActiveEditor={fileContext.focusActiveEditor}
|
onFocusActiveEditor={fileContext.focusActiveEditor}
|
||||||
@@ -537,12 +563,15 @@ export const App: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onAttachContext={handleAttachContextClick}
|
onAttachContext={handleAttachContextClick}
|
||||||
completionIsOpen={completion.isOpen}
|
completionIsOpen={completion.isOpen}
|
||||||
|
completionItems={completion.items}
|
||||||
|
onCompletionSelect={handleCompletionSelect}
|
||||||
|
onCompletionClose={completion.closeCompletion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SaveSessionDialog
|
<SaveSessionDialog
|
||||||
isOpen={showSaveDialog}
|
isOpen={showSaveDialog}
|
||||||
onClose={() => setShowSaveDialog(false)}
|
onClose={() => setShowSaveDialog(false)}
|
||||||
onSave={sessionManagement.handleSaveSession}
|
onSave={handleSaveSession}
|
||||||
existingTags={sessionManagement.savedSessionTags}
|
existingTags={sessionManagement.savedSessionTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -556,14 +585,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{completion.isOpen && completion.items.length > 0 && (
|
{/* Claude-style dropdown is rendered inside InputForm for proper anchoring */}
|
||||||
<CompletionMenu
|
|
||||||
items={completion.items}
|
|
||||||
position={completion.position}
|
|
||||||
onSelect={handleCompletionSelect}
|
|
||||||
onClose={completion.closeCompletion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
/* Import component styles */
|
/* Import component styles */
|
||||||
@import './components/SaveSessionDialog.css';
|
@import './components/SaveSessionDialog.css';
|
||||||
@import './components/SessionManager.css';
|
@import './components/SessionManager.css';
|
||||||
@import './components/MessageContent.css';
|
|
||||||
@import './components/EmptyState.css';
|
@import './components/EmptyState.css';
|
||||||
@import './components/CompletionMenu.css';
|
@import './components/CompletionMenu.css';
|
||||||
@import './components/ContextPills.css';
|
@import './components/ContextPills.css';
|
||||||
@import './components/PermissionDrawer.css';
|
|
||||||
@import './components/PlanDisplay.css';
|
@import './components/PlanDisplay.css';
|
||||||
@import './components/Timeline.css';
|
@import './components/Timeline.css';
|
||||||
@import './components/shared/FileLink.css';
|
@import './components/shared/FileLink.css';
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { ConversationStore } from '../storage/conversationStore.js';
|
|||||||
import type { AcpPermissionRequest } from '../constants/acpTypes.js';
|
import type { AcpPermissionRequest } from '../constants/acpTypes.js';
|
||||||
import { CliDetector } from '../cli/cliDetector.js';
|
import { CliDetector } from '../cli/cliDetector.js';
|
||||||
import { AuthStateManager } from '../auth/authStateManager.js';
|
import { AuthStateManager } from '../auth/authStateManager.js';
|
||||||
import { PanelManager } from './PanelManager.js';
|
import { PanelManager } from '../webview/PanelManager.js';
|
||||||
import { MessageHandler } from './MessageHandler.js';
|
import { MessageHandler } from '../webview/MessageHandler.js';
|
||||||
import { WebViewContent } from './WebViewContent.js';
|
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||||
import { CliInstaller } from '../cli/CliInstaller.js';
|
import { CliInstaller } from '../cli/CliInstaller.js';
|
||||||
import { getFileName } from './utils/webviewUtils.js';
|
import { getFileName } from './utils/webviewUtils.js';
|
||||||
|
|
||||||
@@ -82,6 +82,15 @@ export class WebViewProvider {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup end-turn handler from ACP stopReason=end_turn
|
||||||
|
this.agentManager.onEndTurn(() => {
|
||||||
|
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
||||||
|
this.sendMessageToWebView({
|
||||||
|
type: 'streamEnd',
|
||||||
|
data: { timestamp: Date.now(), reason: 'end_turn' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
|
||||||
// and sent via onStreamChunk callback
|
// and sent via onStreamChunk callback
|
||||||
this.agentManager.onToolCall((update) => {
|
this.agentManager.onToolCall((update) => {
|
||||||
@@ -365,6 +374,10 @@ export class WebViewProvider {
|
|||||||
'[WebViewProvider] Starting initialization, workingDir:',
|
'[WebViewProvider] Starting initialization, workingDir:',
|
||||||
workingDir,
|
workingDir,
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
'[WebViewProvider] AuthStateManager available:',
|
||||||
|
!!this.authStateManager,
|
||||||
|
);
|
||||||
|
|
||||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||||
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
|
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
|
||||||
@@ -604,11 +617,17 @@ export class WebViewProvider {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WebViewProvider] Force re-login failed:', error);
|
console.error('[WebViewProvider] Force re-login failed:', error);
|
||||||
|
console.error(
|
||||||
|
'[WebViewProvider] Error stack:',
|
||||||
|
error instanceof Error ? error.stack : 'N/A',
|
||||||
|
);
|
||||||
|
|
||||||
// Send error notification to WebView
|
// Send error notification to WebView
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'loginError',
|
type: 'loginError',
|
||||||
data: { message: `Login failed: ${error}` },
|
data: {
|
||||||
|
message: `Login failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Claude Code-like dropdown anchored to input container */
|
||||||
|
.hi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--app-menu-background);
|
||||||
|
border: 1px solid var(--app-input-border);
|
||||||
|
border-radius: var(--corner-radius-large);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: So .15s ease-out;
|
||||||
|
max-height: 50vh;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional top spacer to create visual separation from input */
|
||||||
|
.hi > .spacer-4px { height: 4px; }
|
||||||
|
|
||||||
|
.xi {
|
||||||
|
max-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--app-list-padding);
|
||||||
|
gap: var(--app-list-gap);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi { /* divider */
|
||||||
|
height: 1px;
|
||||||
|
background: var(--app-input-border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vi { /* section label */
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
opacity: .5;
|
||||||
|
font-size: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wi { /* item */
|
||||||
|
padding: var(--app-list-item-padding);
|
||||||
|
margin: 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--app-list-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ki { /* item content */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Ii { /* leading icon */
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--vscode-symbolIcon-fileForeground, #cccccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.Lo { /* primary text */
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Mo { /* secondary text (path/description) */
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
opacity: .7;
|
||||||
|
font-size: .9em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jo { /* active/selected */
|
||||||
|
background: var(--app-list-active-background);
|
||||||
|
color: var(--app-list-active-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jo .Lo { color: var(--app-list-active-foreground); }
|
||||||
|
|
||||||
|
.yi { /* trailing icon placeholder */
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
opacity: .5;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes So {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container around the input to anchor the dropdown */
|
||||||
|
.Bo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import './ClaudeCompletionMenu.css';
|
||||||
|
import type { CompletionItem } from './CompletionMenu.js';
|
||||||
|
|
||||||
|
interface ClaudeCompletionMenuProps {
|
||||||
|
items: CompletionItem[];
|
||||||
|
onSelect: (item: CompletionItem) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
selectedIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Code-like anchored dropdown rendered above the input field.
|
||||||
|
* Keyboard: Up/Down to move, Enter to select, Esc to close.
|
||||||
|
*/
|
||||||
|
export const ClaudeCompletionMenu: React.FC<ClaudeCompletionMenuProps> = ({
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
selectedIndex = 0,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [selected, setSelected] = useState(selectedIndex);
|
||||||
|
|
||||||
|
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((prev) => Math.min(prev + 1, items.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelected((prev) => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
if (items[selected]) {
|
||||||
|
onSelect(items[selected]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [items, selected, onSelect, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedEl = containerRef.current?.querySelector(
|
||||||
|
`[data-index="${selected}"]`,
|
||||||
|
);
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="hi" role="menu">
|
||||||
|
<div className="spacer-4px" />
|
||||||
|
<div className="xi">
|
||||||
|
{title && <div className="vi">{title}</div>}
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const selectedCls = index === selected ? 'jo' : '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
data-index={index}
|
||||||
|
className={`wi ${selectedCls}`}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
onMouseEnter={() => setSelected(index)}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<div className="ki">
|
||||||
|
{item.icon && <span className="Ii">{item.icon}</span>}
|
||||||
|
<span className="Lo">{item.label}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="Mo" title={item.description}>
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,8 +13,11 @@ export interface CompletionItem {
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
type: 'file' | 'symbol' | 'command' | 'variable';
|
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
|
||||||
value?: unknown;
|
// Value inserted into the input when selected (e.g., filename or command)
|
||||||
|
value?: string;
|
||||||
|
// Optional full path for files (used to build @filename -> full path mapping)
|
||||||
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompletionMenuProps {
|
interface CompletionMenuProps {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
* In-progress tool call component - displays active tool calls with Claude Code style
|
* In-progress tool call component - displays active tool calls with Claude Code style
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import React from 'react';
|
||||||
import type { ToolCallData } from './toolcalls/shared/types.js';
|
import type { ToolCallData } from './toolcalls/shared/types.js';
|
||||||
import { FileLink } from './shared/FileLink.js';
|
import { FileLink } from './shared/FileLink.js';
|
||||||
|
import { useVSCode } from '../hooks/useVSCode.js';
|
||||||
|
|
||||||
interface InProgressToolCallProps {
|
interface InProgressToolCallProps {
|
||||||
toolCall: ToolCallData;
|
toolCall: ToolCallData;
|
||||||
|
onFileClick?: (path: string, line?: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,65 +42,158 @@ const formatKind = (kind: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get status display text
|
* Get file name from path
|
||||||
*/
|
*/
|
||||||
const getStatusText = (status: string): string => {
|
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||||
const statusMap: Record<string, string> = {
|
|
||||||
pending: 'Pending',
|
|
||||||
in_progress: 'In Progress',
|
|
||||||
completed: 'Completed',
|
|
||||||
failed: 'Failed',
|
|
||||||
};
|
|
||||||
|
|
||||||
return statusMap[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to display in-progress tool calls with Claude Code styling
|
* Component to display in-progress tool calls with Claude Code styling
|
||||||
* Shows kind, status, and file locations
|
* Shows kind, file name, and file locations
|
||||||
*/
|
*/
|
||||||
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
||||||
toolCall,
|
toolCall,
|
||||||
|
onFileClick: _onFileClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { kind, status, title, locations } = toolCall;
|
const { kind, title, locations, content } = toolCall;
|
||||||
|
const vscode = useVSCode();
|
||||||
|
|
||||||
// Format the kind label
|
// Format the kind label
|
||||||
const kindLabel = formatKind(kind);
|
const kindLabel = formatKind(kind);
|
||||||
|
|
||||||
// Get status text
|
// Map tool kind to a Tailwind text color class (Claude-like palette)
|
||||||
const statusText = getStatusText(status || 'in_progress');
|
const kindColorClass = React.useMemo(() => {
|
||||||
|
const k = kind.toLowerCase();
|
||||||
|
if (k === 'read') {
|
||||||
|
return 'text-[#4ec9b0]';
|
||||||
|
}
|
||||||
|
if (k === 'write' || k === 'edit') {
|
||||||
|
return 'text-[#e5c07b]';
|
||||||
|
}
|
||||||
|
if (k === 'execute' || k === 'bash' || k === 'command') {
|
||||||
|
return 'text-[#c678dd]';
|
||||||
|
}
|
||||||
|
if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') {
|
||||||
|
return 'text-[#61afef]';
|
||||||
|
}
|
||||||
|
if (k === 'think' || k === 'thinking') {
|
||||||
|
return 'text-[#98c379]';
|
||||||
|
}
|
||||||
|
return 'text-[var(--app-primary-foreground)]';
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
// Safely prepare a display value for title. Titles may sometimes arrive as
|
// Get file name from locations or title
|
||||||
// non-string objects; ensure we render a string in that case.
|
let fileName: string | null = null;
|
||||||
const titleText = typeof title === 'string' ? title : undefined;
|
let filePath: string | null = null;
|
||||||
const titleDisplay: React.ReactNode =
|
let fileLine: number | null = null;
|
||||||
typeof title === 'string' ? title : title ? JSON.stringify(title) : null;
|
|
||||||
|
if (locations && locations.length > 0) {
|
||||||
|
fileName = getFileName(locations[0].path);
|
||||||
|
filePath = locations[0].path;
|
||||||
|
fileLine = locations[0].line || null;
|
||||||
|
} else if (typeof title === 'string') {
|
||||||
|
fileName = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content text from content array
|
||||||
|
let contentText: string | null = null;
|
||||||
|
// Extract first diff (if present)
|
||||||
|
let diffData: {
|
||||||
|
path?: string;
|
||||||
|
oldText?: string | null;
|
||||||
|
newText?: string;
|
||||||
|
} | null = null;
|
||||||
|
if (content && content.length > 0) {
|
||||||
|
// Look for text content
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === 'content' && item.content?.text) {
|
||||||
|
contentText = item.content.text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no text content found, look for other content types
|
||||||
|
if (!contentText) {
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === 'content' && item.content) {
|
||||||
|
contentText = JSON.stringify(item.content, null, 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for diff content
|
||||||
|
for (const item of content) {
|
||||||
|
if (
|
||||||
|
item.type === 'diff' &&
|
||||||
|
(item.oldText !== undefined || item.newText !== undefined)
|
||||||
|
) {
|
||||||
|
diffData = {
|
||||||
|
path: item.path,
|
||||||
|
oldText: item.oldText ?? null,
|
||||||
|
newText: item.newText,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle open diff
|
||||||
|
const handleOpenDiff = () => {
|
||||||
|
if (!diffData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = diffData.path || filePath || '';
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'openDiff',
|
||||||
|
data: {
|
||||||
|
path,
|
||||||
|
oldText: diffData.oldText || '',
|
||||||
|
newText: diffData.newText || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="in-progress-tool-call">
|
<div className="relative py-2 toolcall-container">
|
||||||
<div className="in-progress-tool-call-header">
|
<div className="flex items-center gap-2 mb-1 relative">
|
||||||
<span className="in-progress-tool-call-kind">{kindLabel}</span>
|
{/* Pulsing bullet dot (Claude-style), vertically centered with header row */}
|
||||||
<span
|
<span
|
||||||
className={`in-progress-tool-call-status ${status || 'in_progress'}`}
|
aria-hidden
|
||||||
|
className="absolute -left-[20px] top-1/2 -translate-y-1/2 text-[10px] leading-none text-[#e1c08d] animate-[pulse_1.5s_ease-in-out_infinite]"
|
||||||
>
|
>
|
||||||
{statusText}
|
●
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`text-xs font-medium ${kindColorClass}`}>
|
||||||
|
{kindLabel}
|
||||||
|
</span>
|
||||||
|
{filePath && (
|
||||||
|
<FileLink
|
||||||
|
path={filePath}
|
||||||
|
line={fileLine ?? undefined}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!filePath && fileName && (
|
||||||
|
<span className="text-xs text-[var(--app-secondary-foreground)] font-mono">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffData && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenDiff}
|
||||||
|
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
|
||||||
|
>
|
||||||
|
Open Diff
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{titleDisplay && (titleText ? titleText !== kindLabel : true) && (
|
{contentText && (
|
||||||
<div className="in-progress-tool-call-title">{titleDisplay}</div>
|
<div className="text-xs text-[var(--app-secondary-foreground)] font-mono mt-1 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto p-1 bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded">
|
||||||
)}
|
{contentText}
|
||||||
|
|
||||||
{locations && locations.length > 0 && (
|
|
||||||
<div className="in-progress-tool-call-locations">
|
|
||||||
{locations.map((loc, idx) => (
|
|
||||||
<FileLink
|
|
||||||
key={idx}
|
|
||||||
path={loc.path}
|
|
||||||
line={loc.line}
|
|
||||||
showFullPath={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
LinkIcon,
|
LinkIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
} from './icons/index.js';
|
} from './icons/index.js';
|
||||||
|
import { ClaudeCompletionMenu } from './ClaudeCompletionMenu.js';
|
||||||
|
import type { CompletionItem } from './CompletionMenu.js';
|
||||||
|
|
||||||
type EditMode = 'ask' | 'auto' | 'plan';
|
type EditMode = 'ask' | 'auto' | 'plan';
|
||||||
|
|
||||||
@@ -40,6 +42,9 @@ interface InputFormProps {
|
|||||||
onShowCommandMenu: () => void;
|
onShowCommandMenu: () => void;
|
||||||
onAttachContext: () => void;
|
onAttachContext: () => void;
|
||||||
completionIsOpen: boolean;
|
completionIsOpen: boolean;
|
||||||
|
completionItems?: CompletionItem[];
|
||||||
|
onCompletionSelect?: (item: CompletionItem) => void;
|
||||||
|
onCompletionClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get edit mode display info
|
// Get edit mode display info
|
||||||
@@ -92,6 +97,10 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
onShowCommandMenu,
|
onShowCommandMenu,
|
||||||
onAttachContext,
|
onAttachContext,
|
||||||
completionIsOpen,
|
completionIsOpen,
|
||||||
|
// Claude-style completion dropdown (optional)
|
||||||
|
completionItems,
|
||||||
|
onCompletionSelect,
|
||||||
|
onCompletionClose,
|
||||||
}) => {
|
}) => {
|
||||||
const editModeInfo = getEditModeInfo(editMode);
|
const editModeInfo = getEditModeInfo(editMode);
|
||||||
|
|
||||||
@@ -133,12 +142,27 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
{/* Banner area */}
|
{/* Banner area */}
|
||||||
<div className="input-banner" />
|
<div className="input-banner" />
|
||||||
|
|
||||||
{/* Input wrapper */}
|
{/* Input wrapper (Claude-style anchor container) */}
|
||||||
<div className="relative flex z-[1]">
|
<div className="relative flex z-[1] Bo">
|
||||||
|
{/* Claude-style anchored dropdown */}
|
||||||
|
{completionIsOpen &&
|
||||||
|
completionItems &&
|
||||||
|
completionItems.length > 0 &&
|
||||||
|
onCompletionSelect &&
|
||||||
|
onCompletionClose && (
|
||||||
|
// Render dropdown above the input, matching Claude Code
|
||||||
|
<ClaudeCompletionMenu
|
||||||
|
items={completionItems}
|
||||||
|
onSelect={onCompletionSelect}
|
||||||
|
onClose={onCompletionClose}
|
||||||
|
title={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={inputFieldRef}
|
ref={inputFieldRef}
|
||||||
contentEditable="plaintext-only"
|
contentEditable="plaintext-only"
|
||||||
className="flex-1 self-stretch p-2.5 px-3.5 outline-none font-inherit leading-relaxed overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-none rounded-none overflow-x-hidden break-words whitespace-pre-wrap empty:before:content-[attr(data-placeholder)] empty:before:absolute empty:before:pointer-events-none disabled:text-gray-400 disabled:cursor-not-allowed"
|
className="c flex-1 self-stretch p-2.5 px-3.5 outline-none font-inherit leading-relaxed overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-none rounded-none overflow-x-hidden break-words whitespace-pre-wrap empty:before:content-[attr(data-placeholder)] empty:before:absolute empty:before:pointer-events-none disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--app-input-foreground)',
|
color: 'var(--app-input-foreground)',
|
||||||
fontSize: 'var(--vscode-chat-font-size, 13px)',
|
fontSize: 'var(--vscode-chat-font-size, 13px)',
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export const MessageContent: React.FC<MessageContentProps> = ({
|
|||||||
parts.push(
|
parts.push(
|
||||||
<code
|
<code
|
||||||
key={`inline-${matchIndex}`}
|
key={`inline-${matchIndex}`}
|
||||||
className="rounded px-1.5 py-0.5 whitespace-nowrap text-[0.9em]"
|
className="rounded px-1.5 py-0.5 whitespace-nowrap text-[0.9em] inline-block max-w-full overflow-hidden text-ellipsis align-baseline"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--app-code-background, rgba(0, 0, 0, 0.05))',
|
backgroundColor: 'var(--app-code-background, rgba(0, 0, 0, 0.05))',
|
||||||
border: '1px solid var(--app-primary-border-color)',
|
border: '1px solid var(--app-primary-border-color)',
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
|||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Number keys 1-9 for quick select
|
// Number keys 1-9 for quick select
|
||||||
const numMatch = e.key.match(/^[1-9]$/);
|
const numMatch = e.key.match(/^[1-9]$/);
|
||||||
@@ -123,7 +125,9 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
||||||
@@ -162,18 +166,11 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.optionId}
|
key={option.optionId}
|
||||||
className={`flex items-center gap-2 px-3 py-2 text-left rounded-small border transition-colors duration-150 ${
|
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] ${
|
||||||
isFocused
|
isFocused
|
||||||
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
|
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
|
||||||
: 'hover:bg-[var(--app-list-hover-background)]'
|
: 'hover:bg-[var(--app-list-hover-background)]'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
|
||||||
color: isFocused
|
|
||||||
? 'var(--app-list-active-foreground)'
|
|
||||||
: 'var(--app-primary-foreground)',
|
|
||||||
borderColor:
|
|
||||||
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
|
|
||||||
}}
|
|
||||||
onClick={() => onResponse(option.optionId)}
|
onClick={() => onResponse(option.optionId)}
|
||||||
onMouseEnter={() => setFocusedIndex(index)}
|
onMouseEnter={() => setFocusedIndex(index)}
|
||||||
>
|
>
|
||||||
@@ -197,39 +194,60 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom message input */}
|
{/* Custom message input (styled consistently with option items) */}
|
||||||
<input
|
{(() => {
|
||||||
ref={customInputRef as React.RefObject<HTMLInputElement>}
|
const isFocused = focusedIndex === options.length;
|
||||||
type="text"
|
return (
|
||||||
placeholder="Tell Qwen what to do instead"
|
<div
|
||||||
spellCheck={false}
|
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 cursor-text text-[var(--app-primary-foreground)] ${
|
||||||
className={`w-full px-3 py-2 text-sm rounded-small border transition-colors duration-150 ${
|
isFocused
|
||||||
focusedIndex === options.length
|
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
|
||||||
? 'bg-[var(--app-list-hover-background)]'
|
: 'hover:bg-[var(--app-list-hover-background)]'
|
||||||
: 'bg-transparent'
|
}`}
|
||||||
}`}
|
onMouseEnter={() => setFocusedIndex(options.length)}
|
||||||
style={{
|
onClick={() => customInputRef.current?.focus()}
|
||||||
color: 'var(--app-input-foreground)',
|
>
|
||||||
outline: 'none',
|
{/* Number badge (N+1) */}
|
||||||
borderColor:
|
<span
|
||||||
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
|
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
|
||||||
}}
|
isFocused
|
||||||
value={customMessage}
|
? 'bg-white/20 text-inherit'
|
||||||
onChange={(e) => setCustomMessage(e.target.value)}
|
: 'bg-[var(--app-list-hover-background)]'
|
||||||
onFocus={() => setFocusedIndex(options.length)}
|
}`}
|
||||||
onMouseEnter={() => setFocusedIndex(options.length)}
|
>
|
||||||
onKeyDown={(e) => {
|
{options.length + 1}
|
||||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
</span>
|
||||||
e.preventDefault();
|
|
||||||
const rejectOption = options.find((o) =>
|
{/* Input field */}
|
||||||
o.kind.includes('reject'),
|
<input
|
||||||
);
|
ref={customInputRef as React.RefObject<HTMLInputElement>}
|
||||||
if (rejectOption) {
|
type="text"
|
||||||
onResponse(rejectOption.optionId);
|
placeholder="Tell Qwen what to do instead"
|
||||||
}
|
spellCheck={false}
|
||||||
}
|
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||||
}}
|
style={{ color: 'var(--app-input-foreground)' }}
|
||||||
/>
|
value={customMessage}
|
||||||
|
onChange={(e) => setCustomMessage(e.target.value)}
|
||||||
|
onFocus={() => setFocusedIndex(options.length)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (
|
||||||
|
e.key === 'Enter' &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
customMessage.trim()
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
const rejectOption = options.find((o) =>
|
||||||
|
o.kind.includes('reject'),
|
||||||
|
);
|
||||||
|
if (rejectOption) {
|
||||||
|
onResponse(rejectOption.optionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,115 +5,83 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlanDisplay.css - Styles for the task plan component
|
* PlanDisplay.css -> Tailwind 化
|
||||||
* Clean checklist-style design matching Claude Code CLI
|
* 说明:尽量用 @apply,把原有类名保留,便于调试;
|
||||||
|
* 仅在必须的地方保留少量原生 CSS(如关键帧)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* 容器 */
|
||||||
.plan-display {
|
.plan-display {
|
||||||
background: transparent;
|
@apply bg-transparent border-0 py-2 px-4 my-2;
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 标题区 */
|
||||||
.plan-header {
|
.plan-header {
|
||||||
display: flex;
|
@apply flex items-center gap-1.5 mb-2;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-progress-icons {
|
.plan-progress-icons {
|
||||||
display: flex;
|
@apply flex items-center gap-[2px];
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-progress-icon {
|
.plan-progress-icon {
|
||||||
flex-shrink: 0;
|
@apply shrink-0 text-[var(--app-secondary-foreground)] opacity-60;
|
||||||
color: var(--app-secondary-foreground);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-title {
|
.plan-title {
|
||||||
font-size: 12px;
|
@apply text-xs font-normal text-[var(--app-secondary-foreground)] opacity-80;
|
||||||
font-weight: 400;
|
|
||||||
color: var(--app-secondary-foreground);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 列表 */
|
||||||
.plan-entries {
|
.plan-entries {
|
||||||
display: flex;
|
@apply flex flex-col gap-px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry {
|
.plan-entry {
|
||||||
display: flex;
|
@apply flex items-center gap-2 py-[3px] min-h-[20px];
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 3px 0;
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icon container */
|
/* 图标容器(保留类名以兼容旧 DOM) */
|
||||||
.plan-entry-icon {
|
.plan-entry-icon {
|
||||||
flex-shrink: 0;
|
@apply shrink-0 flex items-center justify-center w-[14px] h-[14px];
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-icon {
|
.plan-icon {
|
||||||
display: block;
|
@apply block w-[14px] h-[14px];
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 不同状态的图标颜色 */
|
/* 不同状态颜色(保留类名) */
|
||||||
.plan-icon.pending {
|
.plan-icon.pending {
|
||||||
color: var(--app-secondary-foreground);
|
@apply text-[var(--app-secondary-foreground)] opacity-30;
|
||||||
opacity: 0.35;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-icon.in-progress {
|
.plan-icon.in-progress {
|
||||||
color: var(--app-secondary-foreground);
|
@apply text-[var(--app-secondary-foreground)] opacity-70;
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-icon.completed {
|
.plan-icon.completed {
|
||||||
color: #4caf50; /* 绿色勾号 */
|
@apply text-[#4caf50] opacity-80; /* 绿色勾号 */
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content */
|
/* 内容 */
|
||||||
.plan-entry-content {
|
.plan-entry-content {
|
||||||
flex: 1;
|
@apply flex-1 flex items-center;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry-text {
|
.plan-entry-text {
|
||||||
flex: 1;
|
@apply flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)] opacity-80;
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--app-primary-foreground);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status-specific styles */
|
/* 状态化文本(保留选择器,兼容旧结构) */
|
||||||
.plan-entry.completed .plan-entry-text {
|
.plan-entry.completed .plan-entry-text {
|
||||||
opacity: 0.5;
|
@apply opacity-50 line-through;
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry.in_progress .plan-entry-text {
|
.plan-entry.in_progress .plan-entry-text {
|
||||||
font-weight: 400;
|
@apply font-normal opacity-90;
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 保留 fadeIn 动画,供 App.tsx 使用 */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {
|
|
||||||
PlanCompletedIcon,
|
|
||||||
PlanInProgressIcon,
|
|
||||||
PlanPendingIcon,
|
|
||||||
} from './icons/index.js';
|
|
||||||
import './PlanDisplay.css';
|
import './PlanDisplay.css';
|
||||||
|
import { CheckboxDisplay } from './ui/CheckboxDisplay.js';
|
||||||
|
|
||||||
export interface PlanEntry {
|
export interface PlanEntry {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -26,42 +22,76 @@ 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 }) => {
|
||||||
// 计算完成进度
|
// 计算整体状态用于左侧圆点颜色
|
||||||
const completedCount = entries.filter((e) => e.status === 'completed').length;
|
const allCompleted =
|
||||||
const totalCount = entries.length;
|
entries.length > 0 && entries.every((e) => e.status === 'completed');
|
||||||
|
const anyInProgress = entries.some((e) => e.status === 'in_progress');
|
||||||
const getStatusIcon = (status: string) => {
|
const statusDotClass = allCompleted
|
||||||
switch (status) {
|
? 'before:text-[#74c991]'
|
||||||
case 'completed':
|
: anyInProgress
|
||||||
return <PlanCompletedIcon className="plan-icon completed" />;
|
? 'before:text-[#e1c08d]'
|
||||||
case 'in_progress':
|
: 'before:text-[var(--app-secondary-foreground)]';
|
||||||
return <PlanInProgressIcon className="plan-icon in-progress" />;
|
|
||||||
default:
|
|
||||||
// pending
|
|
||||||
return <PlanPendingIcon className="plan-icon pending" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-display">
|
<div
|
||||||
<div className="plan-header">
|
className={[
|
||||||
<div className="plan-progress-icons">
|
'plan-display',
|
||||||
<PlanPendingIcon className="plan-progress-icon" />
|
// 容器:类似示例中的 .A/.e
|
||||||
<PlanCompletedIcon className="plan-progress-icon" />
|
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
|
||||||
</div>
|
// 左侧状态圆点,类似示例 .e:before
|
||||||
<span className="plan-title">
|
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
|
||||||
{completedCount} of {totalCount} Done
|
statusDotClass,
|
||||||
</span>
|
].join(' ')}
|
||||||
</div>
|
>
|
||||||
<div className="plan-entries">
|
{/* 标题区域,类似示例中的 summary/_e/or */}
|
||||||
{entries.map((entry, index) => (
|
<div className="plan-header w-full">
|
||||||
<div key={index} className={`plan-entry ${entry.status}`}>
|
<div className="relative">
|
||||||
<div className="plan-entry-icon">{getStatusIcon(entry.status)}</div>
|
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
|
||||||
<div className="plan-entry-content">
|
<span>
|
||||||
<span className="plan-entry-text">{entry.content}</span>
|
<div>
|
||||||
</div>
|
<span className="or font-bold mr-1">Update Todos</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 列表区域,类似示例中的 .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) => {
|
||||||
|
const isDone = entry.status === 'completed';
|
||||||
|
const isIndeterminate = entry.status === 'in_progress';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={[
|
||||||
|
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
|
||||||
|
isDone ? 'fo opacity-70' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{/* 展示用复选框(复用组件) */}
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<CheckboxDisplay
|
||||||
|
checked={isDone}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'vo plan-entry-text flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
|
||||||
|
isDone
|
||||||
|
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
|
||||||
|
: 'opacity-85',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{entry.content}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,3 +82,26 @@ export const SaveDocumentIcon: React.FC<IconProps> = ({
|
|||||||
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
|
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder icon (16x16)
|
||||||
|
* Useful for directory entries in completion lists
|
||||||
|
*/
|
||||||
|
export const FolderIcon: React.FC<IconProps> = ({
|
||||||
|
size = 16,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.086a1.5 1.5 0 0 1 1.06.44L8.5 3H13A1.5 1.5 0 0 1 14.5 4.5v7A1.5 1.5 0 0 1 13 13H3A1.5 1.5 0 0 1 1.5 11.5v-8Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,7 +10,12 @@
|
|||||||
export type { IconProps } from './types.js';
|
export type { IconProps } from './types.js';
|
||||||
|
|
||||||
// File icons
|
// File icons
|
||||||
export { FileIcon, FileListIcon, SaveDocumentIcon } from './FileIcons.js';
|
export {
|
||||||
|
FileIcon,
|
||||||
|
FileListIcon,
|
||||||
|
SaveDocumentIcon,
|
||||||
|
FolderIcon,
|
||||||
|
} from './FileIcons.js';
|
||||||
|
|
||||||
// Navigation icons
|
// Navigation icons
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
|||||||
onFileClick,
|
onFileClick,
|
||||||
status = 'default',
|
status = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
|
// 空内容直接不渲染,避免只显示 ::before 的圆点导致观感不佳
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Map status to CSS class (only for ::before pseudo-element)
|
// Map status to CSS class (only for ::before pseudo-element)
|
||||||
const getStatusClass = () => {
|
const getStatusClass = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ToolCallData } from '../toolcalls/shared/types.js';
|
||||||
|
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
|
||||||
|
|
||||||
|
describe('Message Ordering', () => {
|
||||||
|
it('should correctly identify tool calls with output', () => {
|
||||||
|
// Test failed tool call (should show)
|
||||||
|
const failedToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-1',
|
||||||
|
kind: 'read',
|
||||||
|
title: 'Read file',
|
||||||
|
status: 'failed',
|
||||||
|
timestamp: 1000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(failedToolCall)).toBe(true);
|
||||||
|
|
||||||
|
// Test execute tool call with title (should show)
|
||||||
|
const executeToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-2',
|
||||||
|
kind: 'execute',
|
||||||
|
title: 'ls -la',
|
||||||
|
status: 'completed',
|
||||||
|
timestamp: 2000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(executeToolCall)).toBe(true);
|
||||||
|
|
||||||
|
// Test tool call with content (should show)
|
||||||
|
const contentToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-3',
|
||||||
|
kind: 'read',
|
||||||
|
title: 'Read file',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'content',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: 'File content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: 3000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(contentToolCall)).toBe(true);
|
||||||
|
|
||||||
|
// Test tool call with locations (should show)
|
||||||
|
const locationToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-4',
|
||||||
|
kind: 'read',
|
||||||
|
title: 'Read file',
|
||||||
|
status: 'completed',
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
path: '/path/to/file.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: 4000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(locationToolCall)).toBe(true);
|
||||||
|
|
||||||
|
// Test tool call with title (should show)
|
||||||
|
const titleToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-5',
|
||||||
|
kind: 'generic',
|
||||||
|
title: 'Generic tool call',
|
||||||
|
status: 'completed',
|
||||||
|
timestamp: 5000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(titleToolCall)).toBe(true);
|
||||||
|
|
||||||
|
// Test tool call without output (should not show)
|
||||||
|
const noOutputToolCall: ToolCallData = {
|
||||||
|
toolCallId: 'test-6',
|
||||||
|
kind: 'generic',
|
||||||
|
title: '',
|
||||||
|
status: 'completed',
|
||||||
|
timestamp: 6000,
|
||||||
|
};
|
||||||
|
expect(hasToolCallOutput(noOutputToolCall)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// Generate display text for file context
|
// Generate display text for file context
|
||||||
const getFileContextDisplay = () => {
|
const getFileContextDisplay = () => {
|
||||||
if (!fileContext) return null;
|
if (!fileContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const { fileName, startLine, endLine } = fileContext;
|
const { fileName, startLine, endLine } = fileContext;
|
||||||
if (startLine && endLine) {
|
if (startLine && endLine) {
|
||||||
return startLine === endLine
|
return startLine === endLine
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Subtle shimmering highlight across the loading text */
|
||||||
|
@keyframes waitingMessageShimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text-shimmer {
|
||||||
|
/* Use the theme foreground as the base color, with a moving light band */
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--app-secondary-foreground) 0%,
|
||||||
|
var(--app-secondary-foreground) 40%,
|
||||||
|
rgba(255, 255, 255, 0.95) 50%,
|
||||||
|
var(--app-secondary-foreground) 60%,
|
||||||
|
var(--app-secondary-foreground) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent; /* text color comes from the gradient */
|
||||||
|
animation: waitingMessageShimmer 1.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,27 +5,84 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import './AssistantMessage.css';
|
||||||
|
import './WaitingMessage.css';
|
||||||
|
import { WITTY_LOADING_PHRASES } from '../../../constants/loadingMessages.js';
|
||||||
|
|
||||||
interface WaitingMessageProps {
|
interface WaitingMessageProps {
|
||||||
loadingMessage: string;
|
loadingMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rotate message every few seconds while waiting
|
||||||
|
const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request
|
||||||
|
|
||||||
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
|
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
}) => (
|
}) => {
|
||||||
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85 animate-[fadeIn_0.2s_ease-in]">
|
// Build a phrase list that starts with the provided message (if any), then witty fallbacks
|
||||||
<div className="bg-transparent border-0 py-2 flex items-center gap-2">
|
const phrases = useMemo(() => {
|
||||||
<span className="inline-flex items-center gap-1 mr-0">
|
const set = new Set<string>();
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0s]"></span>
|
const list: string[] = [];
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.2s]"></span>
|
if (loadingMessage && loadingMessage.trim()) {
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.4s]"></span>
|
list.push(loadingMessage);
|
||||||
</span>
|
set.add(loadingMessage);
|
||||||
<span
|
}
|
||||||
className="opacity-70 italic"
|
for (const p of WITTY_LOADING_PHRASES) {
|
||||||
style={{ color: 'var(--app-secondary-foreground)' }}
|
if (!set.has(p)) {
|
||||||
|
list.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [loadingMessage]);
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
// Reset to the first phrase whenever the incoming message changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIndex(0);
|
||||||
|
}, [phrases]);
|
||||||
|
|
||||||
|
// Periodically rotate to a different phrase
|
||||||
|
useEffect(() => {
|
||||||
|
if (phrases.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setIndex((prev) => {
|
||||||
|
// pick a different random index to avoid immediate repeats
|
||||||
|
let next = Math.floor(Math.random() * phrases.length);
|
||||||
|
if (phrases.length > 1) {
|
||||||
|
let guard = 0;
|
||||||
|
while (next === prev && guard < 5) {
|
||||||
|
next = Math.floor(Math.random() * phrases.length);
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, ROTATE_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [phrases]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85 animate-[fadeIn_0.2s_ease-in]">
|
||||||
|
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
|
||||||
|
<div
|
||||||
|
className="assistant-message-container assistant-message-loading"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingLeft: '30px', // reserve space for ::before bullet
|
||||||
|
position: 'relative',
|
||||||
|
paddingTop: '8px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loadingMessage}
|
<span className="opacity-70 italic loading-text-shimmer">
|
||||||
</span>
|
{phrases[index]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { AssistantMessage } from './AssistantMessage.js';
|
|||||||
export { ThinkingMessage } from './ThinkingMessage.js';
|
export { ThinkingMessage } from './ThinkingMessage.js';
|
||||||
export { StreamingMessage } from './StreamingMessage.js';
|
export { StreamingMessage } from './StreamingMessage.js';
|
||||||
export { WaitingMessage } from './WaitingMessage.js';
|
export { WaitingMessage } from './WaitingMessage.js';
|
||||||
|
export { PlanDisplay } from '../PlanDisplay.js';
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import type { BaseToolCallProps } from './shared/types.js';
|
import type { BaseToolCallProps } from './shared/types.js';
|
||||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||||
import { DiffDisplay } from './shared/DiffDisplay.js';
|
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||||
@@ -42,7 +41,6 @@ const getDiffSummary = (
|
|||||||
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||||
const { content, locations, toolCallId } = toolCall;
|
const { content, locations, toolCallId } = toolCall;
|
||||||
const vscode = useVSCode();
|
const vscode = useVSCode();
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
// Group content by type
|
// Group content by type
|
||||||
const { errors, diffs } = groupContent(content);
|
const { errors, diffs } = groupContent(content);
|
||||||
@@ -69,46 +67,66 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
const fileName = path ? getFileName(path) : '';
|
const fileName = path ? getFileName(path) : '';
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<ToolCallContainer
|
||||||
label={fileName ? `Edit ${fileName}` : 'Edit'}
|
label={fileName ? 'Edit' : 'Edit'}
|
||||||
status="error"
|
status="error"
|
||||||
toolCallId={toolCallId}
|
toolCallId={toolCallId}
|
||||||
|
labelSuffix={
|
||||||
|
path ? (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{errors.join('\n')}
|
{errors.join('\n')}
|
||||||
</ToolCallContainer>
|
</ToolCallContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success case with diff: show collapsible format
|
// Success case with diff: show minimal inline preview; clicking the title opens VS Code diff
|
||||||
if (diffs.length > 0) {
|
if (diffs.length > 0) {
|
||||||
const firstDiff = diffs[0];
|
const firstDiff = diffs[0];
|
||||||
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
||||||
const fileName = path ? getFileName(path) : '';
|
// const fileName = path ? getFileName(path) : '';
|
||||||
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
|
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
|
||||||
|
// No hooks here; define a simple click handler scoped to this block
|
||||||
|
const openFirstDiff = () =>
|
||||||
|
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)]"
|
className="relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)]"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={openFirstDiff}
|
||||||
|
title="Open diff in VS Code"
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 top-[10px] text-[10px] text-[#74c991]">
|
<span className="absolute left-2 top-[10px] text-[10px] text-[#74c991]">
|
||||||
●
|
●
|
||||||
</span>
|
</span>
|
||||||
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] max-w-full">
|
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
|
||||||
<div className="flex items-center justify-between">
|
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] min-w-0 max-w-full">
|
||||||
|
<div className="flex items-center justify-between min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
||||||
Edit {fileName}
|
Edit
|
||||||
</span>
|
</span>
|
||||||
{toolCallId && (
|
{path && (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* {toolCallId && (
|
||||||
<span className="text-[10px] opacity-30">
|
<span className="text-[10px] opacity-30">
|
||||||
[{toolCallId.slice(-8)}]
|
[{toolCallId.slice(-8)}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs opacity-60 mr-2">
|
<span className="text-xs opacity-60 ml-2">open</span>
|
||||||
{expanded ? '▼' : '▶'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
||||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||||
@@ -116,26 +134,26 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{/* Content area aligned with bullet indent. Do NOT exceed container width. */}
|
||||||
<div className="ml-[30px] mt-1">
|
{/* For any custom blocks here, keep: min-w-0 max-w-full and avoid extra horizontal padding/margins. */}
|
||||||
{diffs.map(
|
<div className="pl-[30px] mt-1 min-w-0 max-w-full overflow-hidden">
|
||||||
(
|
{diffs.map(
|
||||||
item: import('./shared/types.js').ToolCallContent,
|
(
|
||||||
idx: number,
|
item: import('./shared/types.js').ToolCallContent,
|
||||||
) => (
|
idx: number,
|
||||||
<DiffDisplay
|
) => (
|
||||||
key={`diff-${idx}`}
|
<DiffDisplay
|
||||||
path={item.path}
|
key={`diff-${idx}`}
|
||||||
oldText={item.oldText}
|
path={item.path}
|
||||||
newText={item.newText}
|
oldText={item.oldText}
|
||||||
onOpenDiff={() =>
|
newText={item.newText}
|
||||||
handleOpenDiff(item.path, item.oldText, item.newText)
|
onOpenDiff={() =>
|
||||||
}
|
handleOpenDiff(item.path, item.oldText, item.newText)
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
)}
|
),
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type React from 'react';
|
|||||||
import type { BaseToolCallProps } from './shared/types.js';
|
import type { BaseToolCallProps } from './shared/types.js';
|
||||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||||
import { groupContent } from './shared/utils.js';
|
import { groupContent } from './shared/utils.js';
|
||||||
|
import { FileLink } from '../shared/FileLink.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specialized component for Read tool calls
|
* Specialized component for Read tool calls
|
||||||
@@ -23,17 +24,25 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
const { errors } = groupContent(content);
|
const { errors } = groupContent(content);
|
||||||
|
|
||||||
// Extract filename from path
|
// Extract filename from path
|
||||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
// const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||||
|
|
||||||
// Error case: show error
|
// Error case: show error
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const path = locations?.[0]?.path || '';
|
const path = locations?.[0]?.path || '';
|
||||||
const fileName = path ? getFileName(path) : '';
|
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<ToolCallContainer
|
||||||
label={fileName ? `Read ${fileName}` : 'Read'}
|
label={'Read'}
|
||||||
status="error"
|
status="error"
|
||||||
toolCallId={toolCallId}
|
toolCallId={toolCallId}
|
||||||
|
labelSuffix={
|
||||||
|
path ? (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{errors.join('\n')}
|
{errors.join('\n')}
|
||||||
</ToolCallContainer>
|
</ToolCallContainer>
|
||||||
@@ -42,12 +51,21 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
|
|
||||||
// Success case: show which file was read with filename in label
|
// Success case: show which file was read with filename in label
|
||||||
if (locations && locations.length > 0) {
|
if (locations && locations.length > 0) {
|
||||||
const fileName = getFileName(locations[0].path);
|
const path = locations[0].path;
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<ToolCallContainer
|
||||||
label={`Read ${fileName}`}
|
label={'Read'}
|
||||||
status="success"
|
status="success"
|
||||||
toolCallId={toolCallId}
|
toolCallId={toolCallId}
|
||||||
|
labelSuffix={
|
||||||
|
path ? (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{null}
|
{null}
|
||||||
</ToolCallContainer>
|
</ToolCallContainer>
|
||||||
|
|||||||
@@ -9,7 +9,69 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { BaseToolCallProps } from './shared/types.js';
|
import type { BaseToolCallProps } from './shared/types.js';
|
||||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||||
import { groupContent } from './shared/utils.js';
|
import { groupContent, safeTitle } from './shared/utils.js';
|
||||||
|
import { CheckboxDisplay } from '../ui/CheckboxDisplay.js';
|
||||||
|
|
||||||
|
type EntryStatus = 'pending' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
interface TodoEntry {
|
||||||
|
content: string;
|
||||||
|
status: EntryStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapToolStatusToBullet = (
|
||||||
|
status: import('./shared/types.js').ToolCallStatus,
|
||||||
|
): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
return 'error';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'warning';
|
||||||
|
case 'pending':
|
||||||
|
return 'loading';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从文本中尽可能解析带有 - [ ] / - [x] 的 todo 列表
|
||||||
|
const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
||||||
|
const text = textOutputs.join('\n');
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const entries: TodoEntry[] = [];
|
||||||
|
|
||||||
|
const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.*)$/;
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(todoRe);
|
||||||
|
if (m) {
|
||||||
|
const mark = m[1];
|
||||||
|
const title = m[2].trim();
|
||||||
|
const status: EntryStatus =
|
||||||
|
mark === 'x' || mark === 'X'
|
||||||
|
? 'completed'
|
||||||
|
: mark === '-'
|
||||||
|
? 'in_progress'
|
||||||
|
: 'pending';
|
||||||
|
if (title) {
|
||||||
|
entries.push({ content: title, status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没匹配到,退化为将非空行当作 pending 条目
|
||||||
|
if (entries.length === 0) {
|
||||||
|
for (const line of lines) {
|
||||||
|
const title = line.trim();
|
||||||
|
if (title) {
|
||||||
|
entries.push({ content: title, status: 'pending' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specialized component for TodoWrite tool calls
|
* Specialized component for TodoWrite tool calls
|
||||||
@@ -18,12 +80,10 @@ import { groupContent } from './shared/utils.js';
|
|||||||
export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
||||||
toolCall,
|
toolCall,
|
||||||
}) => {
|
}) => {
|
||||||
const { content } = toolCall;
|
const { content, status } = toolCall;
|
||||||
|
|
||||||
// Group content by type
|
|
||||||
const { errors, textOutputs } = groupContent(content);
|
const { errors, textOutputs } = groupContent(content);
|
||||||
|
|
||||||
// Error case: show error
|
// 错误优先展示
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer label="Update Todos" status="error">
|
<ToolCallContainer label="Update Todos" status="error">
|
||||||
@@ -32,17 +92,45 @@ export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success case: show simple confirmation
|
const entries = parseTodoEntries(textOutputs);
|
||||||
const outputText =
|
|
||||||
textOutputs.length > 0 ? textOutputs.join(' ') : 'Todos updated';
|
|
||||||
|
|
||||||
// Truncate if too long
|
const label = safeTitle(toolCall.title) || 'Update Todos';
|
||||||
const displayText =
|
|
||||||
outputText.length > 100 ? outputText.substring(0, 100) + '...' : outputText;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer label="Update Todos" status="success">
|
<ToolCallContainer label={label} status={mapToolStatusToBullet(status)}>
|
||||||
{displayText}
|
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||||
|
{entries.map((entry, idx) => {
|
||||||
|
const isDone = entry.status === 'completed';
|
||||||
|
const isIndeterminate = entry.status === 'in_progress';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className={[
|
||||||
|
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
|
||||||
|
isDone ? 'fo opacity-70' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<CheckboxDisplay
|
||||||
|
checked={isDone}
|
||||||
|
indeterminate={isIndeterminate}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'vo flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
|
||||||
|
isDone
|
||||||
|
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
|
||||||
|
: 'opacity-85',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{entry.content}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</ToolCallContainer>
|
</ToolCallContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type React from 'react';
|
|||||||
import type { BaseToolCallProps } from './shared/types.js';
|
import type { BaseToolCallProps } from './shared/types.js';
|
||||||
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
import { ToolCallContainer } from './shared/LayoutComponents.js';
|
||||||
import { groupContent } from './shared/utils.js';
|
import { groupContent } from './shared/utils.js';
|
||||||
|
import { FileLink } from '../shared/FileLink.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specialized component for Write tool calls
|
* Specialized component for Write tool calls
|
||||||
@@ -22,7 +23,7 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
const { errors, textOutputs } = groupContent(content);
|
const { errors, textOutputs } = groupContent(content);
|
||||||
|
|
||||||
// Extract filename from path
|
// Extract filename from path
|
||||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
// const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||||
|
|
||||||
// Extract content to write from rawInput
|
// Extract content to write from rawInput
|
||||||
let writeContent = '';
|
let writeContent = '';
|
||||||
@@ -36,7 +37,6 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
// Error case: show filename + error message + content preview
|
// Error case: show filename + error message + content preview
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const path = locations?.[0]?.path || '';
|
const path = locations?.[0]?.path || '';
|
||||||
const fileName = path ? getFileName(path) : '';
|
|
||||||
const errorMessage = errors.join('\n');
|
const errorMessage = errors.join('\n');
|
||||||
|
|
||||||
// Truncate content preview
|
// Truncate content preview
|
||||||
@@ -47,9 +47,18 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<ToolCallContainer
|
||||||
label={fileName ? `Write ${fileName}` : 'Write'}
|
label={'Write'}
|
||||||
status="error"
|
status="error"
|
||||||
toolCallId={toolCallId}
|
toolCallId={toolCallId}
|
||||||
|
labelSuffix={
|
||||||
|
path ? (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||||
@@ -68,13 +77,22 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
|
|
||||||
// Success case: show filename + line count
|
// Success case: show filename + line count
|
||||||
if (locations && locations.length > 0) {
|
if (locations && locations.length > 0) {
|
||||||
const fileName = getFileName(locations[0].path);
|
const path = locations[0].path;
|
||||||
const lineCount = writeContent.split('\n').length;
|
const lineCount = writeContent.split('\n').length;
|
||||||
return (
|
return (
|
||||||
<ToolCallContainer
|
<ToolCallContainer
|
||||||
label={`Created ${fileName}`}
|
label={'Created'}
|
||||||
status="success"
|
status="success"
|
||||||
toolCallId={toolCallId}
|
toolCallId={toolCallId}
|
||||||
|
labelSuffix={
|
||||||
|
path ? (
|
||||||
|
<FileLink
|
||||||
|
path={path}
|
||||||
|
showFullPath={false}
|
||||||
|
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
|
||||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
紧凑视图样式 - 超简洁版本
|
紧凑视图样式 - 超简洁版本
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.diff-compact-view {
|
.diff-display-container {
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--vscode-editor-background);
|
background: var(--vscode-editor-background);
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diff-compact-actions {
|
.diff-compact-actions {
|
||||||
padding: 4px 10px 6px;
|
padding: 6px 10px 8px;
|
||||||
border-top: 1px solid var(--vscode-panel-border);
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
background: var(--vscode-editorGroupHeader-tabsBackground);
|
background: var(--vscode-editorGroupHeader-tabsBackground);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -108,19 +108,16 @@
|
|||||||
完整视图样式
|
完整视图样式
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.diff-full-view {
|
/* 已移除完整视图,统一为简洁模式 + 预览 */
|
||||||
border: 1px solid var(--vscode-panel-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-header {
|
/* 预览区域(仅变更行) */
|
||||||
display: flex;
|
.diff-preview {
|
||||||
justify-content: space-between;
|
margin: 0;
|
||||||
align-items: center;
|
padding: 8px 10px;
|
||||||
padding: 12px;
|
background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.06));
|
||||||
background: var(--vscode-editorGroupHeader-tabsBackground);
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-file-path {
|
.diff-file-path {
|
||||||
@@ -133,12 +130,32 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-stats-line {
|
.diff-line {
|
||||||
padding: 8px 12px;
|
white-space: pre;
|
||||||
background: var(--vscode-editor-background);
|
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
|
||||||
|
font-size: 0.88em;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.added {
|
||||||
|
background: var(--vscode-diffEditor-insertedLineBackground, rgba(76, 175, 80, 0.18));
|
||||||
|
color: var(--vscode-diffEditor-insertedTextForeground, #b5f1cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.removed {
|
||||||
|
background: var(--vscode-diffEditor-removedLineBackground, rgba(244, 67, 54, 0.18));
|
||||||
|
color: var(--vscode-diffEditor-removedTextForeground, #f6b1a7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.no-change {
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
font-size: 0.9em;
|
opacity: 0.8;
|
||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
}
|
||||||
|
|
||||||
|
.diff-omitted {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-section {
|
.diff-section {
|
||||||
@@ -250,16 +267,6 @@
|
|||||||
.diff-stats {
|
.diff-stats {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diff-header-actions {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useState, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FileLink } from '../../shared/FileLink.js';
|
import { FileLink } from '../../shared/FileLink.js';
|
||||||
import {
|
import {
|
||||||
calculateDiffStats,
|
calculateDiffStats,
|
||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
} from '../../../utils/diffStats.js';
|
} from '../../../utils/diffStats.js';
|
||||||
import { OpenDiffIcon } from '../../icons/index.js';
|
import { OpenDiffIcon } from '../../icons/index.js';
|
||||||
import './DiffDisplay.css';
|
import './DiffDisplay.css';
|
||||||
|
import {
|
||||||
|
computeLineDiff,
|
||||||
|
truncateOps,
|
||||||
|
type DiffOp,
|
||||||
|
} from '../../../utils/simpleDiff.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for DiffDisplay
|
* Props for DiffDisplay
|
||||||
@@ -24,8 +29,8 @@ interface DiffDisplayProps {
|
|||||||
oldText?: string | null;
|
oldText?: string | null;
|
||||||
newText?: string;
|
newText?: string;
|
||||||
onOpenDiff?: () => void;
|
onOpenDiff?: () => void;
|
||||||
/** 默认显示模式:'compact' | 'full' */
|
/** 是否显示统计信息 */
|
||||||
defaultMode?: 'compact' | 'full';
|
showStats?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,20 +42,27 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|||||||
oldText,
|
oldText,
|
||||||
newText,
|
newText,
|
||||||
onOpenDiff,
|
onOpenDiff,
|
||||||
defaultMode = 'compact',
|
showStats = true,
|
||||||
}) => {
|
}) => {
|
||||||
// 视图模式状态:紧凑或完整
|
// 统计信息(仅在文本变化时重新计算)
|
||||||
const [viewMode, setViewMode] = useState<'compact' | 'full'>(defaultMode);
|
|
||||||
|
|
||||||
// 计算 diff 统计信息(仅在文本变化时重新计算)
|
|
||||||
const stats = useMemo(
|
const stats = useMemo(
|
||||||
() => calculateDiffStats(oldText, newText),
|
() => calculateDiffStats(oldText, newText),
|
||||||
[oldText, newText],
|
[oldText, newText],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 渲染紧凑视图
|
// 仅生成变更行(增加/删除),不渲染上下文
|
||||||
const renderCompactView = () => (
|
const ops: DiffOp[] = useMemo(
|
||||||
<div className="diff-compact-view">
|
() => computeLineDiff(oldText, newText),
|
||||||
|
[oldText, newText],
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
items: previewOps,
|
||||||
|
truncated,
|
||||||
|
omitted,
|
||||||
|
} = useMemo(() => truncateOps<DiffOp>(ops), [ops]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diff-display-container">
|
||||||
<div
|
<div
|
||||||
className="diff-compact-clickable"
|
className="diff-compact-clickable"
|
||||||
onClick={onOpenDiff}
|
onClick={onOpenDiff}
|
||||||
@@ -75,87 +87,74 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="diff-stats">
|
{showStats && (
|
||||||
{stats.added > 0 && (
|
<div className="diff-stats" title={formatDiffStatsDetailed(stats)}>
|
||||||
<span className="stat-added">+{stats.added}</span>
|
{stats.added > 0 && (
|
||||||
)}
|
<span className="stat-added">+{stats.added}</span>
|
||||||
{stats.removed > 0 && (
|
)}
|
||||||
<span className="stat-removed">-{stats.removed}</span>
|
{stats.removed > 0 && (
|
||||||
)}
|
<span className="stat-removed">-{stats.removed}</span>
|
||||||
{stats.changed > 0 && (
|
)}
|
||||||
<span className="stat-changed">~{stats.changed}</span>
|
{stats.changed > 0 && (
|
||||||
)}
|
<span className="stat-changed">~{stats.changed}</span>
|
||||||
{stats.total === 0 && (
|
)}
|
||||||
<span className="stat-no-change">No changes</span>
|
{stats.total === 0 && (
|
||||||
)}
|
<span className="stat-no-change">No changes</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="diff-compact-actions">
|
|
||||||
<button
|
|
||||||
className="diff-action-button secondary"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setViewMode('full');
|
|
||||||
}}
|
|
||||||
title="Show full before/after content"
|
|
||||||
>
|
|
||||||
Show Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染完整视图
|
|
||||||
const renderFullView = () => (
|
|
||||||
<div className="diff-full-view">
|
|
||||||
<div className="diff-header">
|
|
||||||
<div className="diff-file-path">
|
|
||||||
{path && <FileLink path={path} showFullPath={true} />}
|
|
||||||
</div>
|
|
||||||
<div className="diff-header-actions">
|
|
||||||
{onOpenDiff && (
|
|
||||||
<button
|
|
||||||
className="diff-action-button primary"
|
|
||||||
onClick={onOpenDiff}
|
|
||||||
title="Open in VS Code diff viewer"
|
|
||||||
>
|
|
||||||
<OpenDiffIcon width="14" height="14" />
|
|
||||||
Open Diff
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 只绘制差异行的预览区域 */}
|
||||||
|
<pre className="diff-preview code-block" aria-label="Diff preview">
|
||||||
|
<div className="code-content">
|
||||||
|
{previewOps.length === 0 && (
|
||||||
|
<div className="diff-line no-change">(no changes)</div>
|
||||||
|
)}
|
||||||
|
{previewOps.map((op, idx) => {
|
||||||
|
if (op.type === 'add') {
|
||||||
|
const line = op.line;
|
||||||
|
return (
|
||||||
|
<div key={`add-${idx}`} className="diff-line added">
|
||||||
|
+{line || ' '}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (op.type === 'remove') {
|
||||||
|
const line = op.line;
|
||||||
|
return (
|
||||||
|
<div key={`rm-${idx}`} className="diff-line removed">
|
||||||
|
-{line || ' '}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{truncated && (
|
||||||
|
<div
|
||||||
|
className="diff-omitted"
|
||||||
|
title={`${omitted} lines omitted in preview`}
|
||||||
|
>
|
||||||
|
… {omitted} lines omitted
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{/* 在预览下方提供显式打开按钮(可选) */}
|
||||||
|
{onOpenDiff && (
|
||||||
|
<div className="diff-compact-actions">
|
||||||
<button
|
<button
|
||||||
className="diff-action-button secondary"
|
className="diff-action-button primary"
|
||||||
onClick={() => setViewMode('compact')}
|
onClick={onOpenDiff}
|
||||||
title="Collapse to compact view"
|
title="Open in VS Code diff viewer"
|
||||||
>
|
>
|
||||||
Collapse
|
<OpenDiffIcon width="14" height="14" />
|
||||||
|
Open Diff
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="diff-stats-line">{formatDiffStatsDetailed(stats)}</div>
|
|
||||||
{oldText !== undefined && (
|
|
||||||
<div className="diff-section">
|
|
||||||
<div className="diff-label">Before:</div>
|
|
||||||
<pre className="code-block">
|
|
||||||
<div className="code-content">{oldText || '(empty)'}</div>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{newText !== undefined && (
|
|
||||||
<div className="diff-section">
|
|
||||||
<div className="diff-label">After:</div>
|
|
||||||
<pre className="code-block">
|
|
||||||
<div className="code-content">{newText}</div>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="diff-display-container">
|
|
||||||
{viewMode === 'compact' ? renderCompactView() : renderFullView()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface ToolCallContainerProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Tool call ID for debugging */
|
/** Tool call ID for debugging */
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
/** Optional trailing content rendered next to label (e.g., clickable filename) */
|
||||||
|
labelSuffix?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,24 +53,30 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
|||||||
label,
|
label,
|
||||||
status = 'success',
|
status = 'success',
|
||||||
children,
|
children,
|
||||||
toolCallId,
|
toolCallId: _toolCallId,
|
||||||
|
labelSuffix,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="relative pl-[30px] py-2 select-text">
|
<div className="relative pl-[30px] py-2 select-text toolcall-container">
|
||||||
<span
|
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||||
className={`absolute left-2 top-[10px] text-[10px] ${getBulletColorClass(status)}`}
|
<div className="flex items-center gap-2 relative min-w-0">
|
||||||
>
|
{/* Status icon (bullet), vertically centered with header row */}
|
||||||
●
|
<span
|
||||||
</span>
|
aria-hidden
|
||||||
<div className="toolcall-content-wrapper flex flex-col gap-1 pl-[30px] max-w-full">
|
className={`absolute -left-[20px] top-1/2 -translate-y-1/2 text-[10px] leading-none ${getBulletColorClass(
|
||||||
<div className="flex items-center gap-2">
|
status,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{toolCallId && (
|
{/* {toolCallId && (
|
||||||
<span className="text-[10px] opacity-30">
|
<span className="text-[10px] opacity-30">
|
||||||
[{toolCallId.slice(-8)}]
|
[{toolCallId.slice(-8)}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)} */}
|
||||||
|
{labelSuffix}
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
|
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
|
||||||
@@ -92,7 +100,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({
|
|||||||
icon: _icon,
|
icon: _icon,
|
||||||
children,
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="ml-[30px] grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in]">
|
<div className="grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in] toolcall-card">
|
||||||
<div className="flex flex-col gap-medium min-w-0">{children}</div>
|
<div className="flex flex-col gap-medium min-w-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -195,7 +203,7 @@ interface LocationsListProps {
|
|||||||
* List of file locations with clickable links
|
* List of file locations with clickable links
|
||||||
*/
|
*/
|
||||||
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
|
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
|
||||||
<div className="toolcall-locations-list flex flex-col gap-1 pl-[30px] max-w-full">
|
<div className="toolcall-locations-list flex flex-col gap-1 max-w-full">
|
||||||
{locations.map((loc, idx) => (
|
{locations.map((loc, idx) => (
|
||||||
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface ToolCallData {
|
|||||||
rawInput?: string | object;
|
rawInput?: string | object;
|
||||||
content?: ToolCallContent[];
|
content?: ToolCallContent[];
|
||||||
locations?: ToolCallLocation[];
|
locations?: ToolCallLocation[];
|
||||||
|
timestamp?: number; // 添加时间戳字段用于消息排序
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface CheckboxDisplayProps {
|
||||||
|
checked?: boolean;
|
||||||
|
indeterminate?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only checkbox styled via Tailwind classes.
|
||||||
|
* - Renders a custom-looking checkbox using appearance-none and pseudo-elements.
|
||||||
|
* - Supports indeterminate (middle) state using the DOM property and a data- attribute.
|
||||||
|
* - Intended for read-only display (disabled by default).
|
||||||
|
*/
|
||||||
|
export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
|
||||||
|
checked = false,
|
||||||
|
indeterminate = false,
|
||||||
|
disabled = true,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const ref = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.indeterminate = !!indeterminate;
|
||||||
|
if (indeterminate) {
|
||||||
|
el.setAttribute('data-indeterminate', 'true');
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('data-indeterminate');
|
||||||
|
}
|
||||||
|
}, [indeterminate, checked]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
disabled={disabled}
|
||||||
|
checked={checked}
|
||||||
|
readOnly
|
||||||
|
aria-checked={indeterminate ? 'mixed' : checked}
|
||||||
|
title={title}
|
||||||
|
style={style}
|
||||||
|
className={[
|
||||||
|
// Base box style (equivalent to .q)
|
||||||
|
'q appearance-none m-[2px] shrink-0 w-4 h-4 relative rounded-[2px] box-border',
|
||||||
|
'border border-[var(--app-input-border)] bg-[var(--app-input-background)] text-[var(--app-primary-foreground)]',
|
||||||
|
'inline-flex items-center justify-center',
|
||||||
|
// Checked visual state
|
||||||
|
'checked:opacity-70 checked:text-[#74c991]',
|
||||||
|
// Checkmark / indeterminate symbol via pseudo-element
|
||||||
|
'after:absolute after:left-1/2 after:top-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:opacity-0 after:pointer-events-none after:antialiased',
|
||||||
|
'checked:after:content-["\\2713"] checked:after:text-[0.9em] checked:after:opacity-100',
|
||||||
|
'data-[indeterminate=true]:text-[#e1c08d] data-[indeterminate=true]:after:content-["\\273d"] data-[indeterminate=true]:after:text-[0.8em] data-[indeterminate=true]:after:opacity-100',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -46,11 +46,20 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
|||||||
private async handleLogin(): Promise<void> {
|
private async handleLogin(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('[AuthMessageHandler] Login requested');
|
console.log('[AuthMessageHandler] Login requested');
|
||||||
|
console.log(
|
||||||
|
'[AuthMessageHandler] Login handler available:',
|
||||||
|
!!this.loginHandler,
|
||||||
|
);
|
||||||
|
|
||||||
// Direct login without additional confirmation
|
// Direct login without additional confirmation
|
||||||
if (this.loginHandler) {
|
if (this.loginHandler) {
|
||||||
|
console.log('[AuthMessageHandler] Calling login handler');
|
||||||
await this.loginHandler();
|
await this.loginHandler();
|
||||||
|
console.log(
|
||||||
|
'[AuthMessageHandler] Login handler completed successfully',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[AuthMessageHandler] Using fallback login method');
|
||||||
// Fallback: show message and use command
|
// Fallback: show message and use command
|
||||||
vscode.window.showInformationMessage(
|
vscode.window.showInformationMessage(
|
||||||
'Please wait while we connect to Qwen Code...',
|
'Please wait while we connect to Qwen Code...',
|
||||||
@@ -59,9 +68,15 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthMessageHandler] Login failed:', error);
|
console.error('[AuthMessageHandler] Login failed:', error);
|
||||||
|
console.error(
|
||||||
|
'[AuthMessageHandler] Error stack:',
|
||||||
|
error instanceof Error ? error.stack : 'N/A',
|
||||||
|
);
|
||||||
this.sendToWebView({
|
this.sendToWebView({
|
||||||
type: 'loginError',
|
type: 'loginError',
|
||||||
data: { message: `Login failed: ${error}` },
|
data: {
|
||||||
|
message: `Login failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export const useMessageHandling = () => {
|
|||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
||||||
const [loadingMessage, setLoadingMessage] = useState('');
|
const [loadingMessage, setLoadingMessage] = useState('');
|
||||||
const [currentStreamContent, setCurrentStreamContent] = useState('');
|
// Track the index of the assistant placeholder message during streaming
|
||||||
|
const streamingMessageIndexRef = useRef<number | null>(null);
|
||||||
// Use ref to store current stream content, avoiding useEffect dependency issues
|
// Track the index of the current aggregated thinking message
|
||||||
const currentStreamContentRef = useRef<string>('');
|
const thinkingMessageIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add message
|
* Add message
|
||||||
@@ -49,41 +49,75 @@ export const useMessageHandling = () => {
|
|||||||
/**
|
/**
|
||||||
* Start streaming response
|
* Start streaming response
|
||||||
*/
|
*/
|
||||||
const startStreaming = useCallback(() => {
|
const startStreaming = useCallback((timestamp?: number) => {
|
||||||
|
// Create an assistant placeholder message immediately so tool calls won't jump before it
|
||||||
|
setMessages((prev) => {
|
||||||
|
// Record index of the placeholder to update on chunks
|
||||||
|
streamingMessageIndexRef.current = prev.length;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
// Use provided timestamp (from extension) to keep ordering stable
|
||||||
|
timestamp: typeof timestamp === 'number' ? timestamp : Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setCurrentStreamContent('');
|
|
||||||
currentStreamContentRef.current = '';
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add stream chunk
|
* Add stream chunk
|
||||||
*/
|
*/
|
||||||
const appendStreamChunk = useCallback((chunk: string) => {
|
const appendStreamChunk = useCallback((chunk: string) => {
|
||||||
setCurrentStreamContent((prev) => {
|
setMessages((prev) => {
|
||||||
const newContent = prev + chunk;
|
let idx = streamingMessageIndexRef.current;
|
||||||
currentStreamContentRef.current = newContent;
|
const next = prev.slice();
|
||||||
return newContent;
|
|
||||||
|
// If there is no active placeholder (e.g., after a tool call), start a new one
|
||||||
|
if (idx === null) {
|
||||||
|
idx = next.length;
|
||||||
|
streamingMessageIndexRef.current = idx;
|
||||||
|
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx < 0 || idx >= next.length) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const target = next[idx];
|
||||||
|
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break current assistant stream segment (e.g., when a tool call starts/updates)
|
||||||
|
* Next incoming chunk will create a new assistant placeholder
|
||||||
|
*/
|
||||||
|
const breakAssistantSegment = useCallback(() => {
|
||||||
|
streamingMessageIndexRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End streaming response
|
* End streaming response
|
||||||
*/
|
*/
|
||||||
const endStreaming = useCallback(() => {
|
const endStreaming = useCallback(() => {
|
||||||
// If there is streaming content, add it as complete assistant message
|
// Finalize streaming; content already lives in the placeholder message
|
||||||
if (currentStreamContentRef.current) {
|
|
||||||
const assistantMessage: TextMessage = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: currentStreamContentRef.current,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, assistantMessage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setIsWaitingForResponse(false);
|
setIsWaitingForResponse(false);
|
||||||
setCurrentStreamContent('');
|
streamingMessageIndexRef.current = null;
|
||||||
currentStreamContentRef.current = '';
|
// Remove the thinking message if it exists (collapse thoughts)
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = thinkingMessageIndexRef.current;
|
||||||
|
thinkingMessageIndexRef.current = null;
|
||||||
|
if (idx === null || idx < 0 || idx >= prev.length) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = prev.slice();
|
||||||
|
next.splice(idx, 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +142,6 @@ export const useMessageHandling = () => {
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
isWaitingForResponse,
|
isWaitingForResponse,
|
||||||
loadingMessage,
|
loadingMessage,
|
||||||
currentStreamContent,
|
|
||||||
|
|
||||||
// Operations
|
// Operations
|
||||||
addMessage,
|
addMessage,
|
||||||
@@ -116,6 +149,36 @@ export const useMessageHandling = () => {
|
|||||||
startStreaming,
|
startStreaming,
|
||||||
appendStreamChunk,
|
appendStreamChunk,
|
||||||
endStreaming,
|
endStreaming,
|
||||||
|
// Thought handling
|
||||||
|
appendThinkingChunk: (chunk: string) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
let idx = thinkingMessageIndexRef.current;
|
||||||
|
const next = prev.slice();
|
||||||
|
if (idx === null) {
|
||||||
|
idx = next.length;
|
||||||
|
thinkingMessageIndexRef.current = idx;
|
||||||
|
next.push({ role: 'thinking', content: '', timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
if (idx >= 0 && idx < next.length) {
|
||||||
|
const target = next[idx];
|
||||||
|
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearThinking: () => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = thinkingMessageIndexRef.current;
|
||||||
|
thinkingMessageIndexRef.current = null;
|
||||||
|
if (idx === null || idx < 0 || idx >= prev.length) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = prev.slice();
|
||||||
|
next.splice(idx, 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
breakAssistantSegment,
|
||||||
setWaitingForResponse,
|
setWaitingForResponse,
|
||||||
clearWaitingForResponse,
|
clearWaitingForResponse,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,14 @@ export function useCompletionTrigger(
|
|||||||
[getCompletionItems],
|
[getCompletionItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshCompletion = useCallback(async () => {
|
||||||
|
if (!state.isOpen || !state.triggerChar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = await getCompletionItems(state.triggerChar, state.query);
|
||||||
|
setState((prev) => ({ ...prev, items }));
|
||||||
|
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const inputElement = inputRef.current;
|
const inputElement = inputRef.current;
|
||||||
if (!inputElement) {
|
if (!inputElement) {
|
||||||
@@ -217,5 +225,6 @@ export function useCompletionTrigger(
|
|||||||
items: state.items,
|
items: state.items,
|
||||||
closeCompletion,
|
closeCompletion,
|
||||||
openCompletion,
|
openCompletion,
|
||||||
|
refreshCompletion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,70 @@ export const useToolCalls = () => {
|
|||||||
const newMap = new Map(prevToolCalls);
|
const newMap = new Map(prevToolCalls);
|
||||||
const existing = newMap.get(update.toolCallId);
|
const existing = newMap.get(update.toolCallId);
|
||||||
|
|
||||||
|
// Helpers for todo/todos plan merging & content replacement
|
||||||
|
const isTodoWrite = (kind?: string) =>
|
||||||
|
(kind || '').toLowerCase() === 'todo_write' ||
|
||||||
|
(kind || '').toLowerCase() === 'todowrite' ||
|
||||||
|
(kind || '').toLowerCase() === 'update_todos';
|
||||||
|
|
||||||
|
const normTitle = (t: unknown) =>
|
||||||
|
typeof t === 'string' ? t.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
const isTodoTitleMergeable = (t?: unknown) => {
|
||||||
|
const nt = normTitle(t);
|
||||||
|
return nt === 'updated plan' || nt === 'update todos';
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractText = (
|
||||||
|
content?: Array<{
|
||||||
|
type: 'content' | 'diff';
|
||||||
|
content?: { text?: string };
|
||||||
|
}>,
|
||||||
|
): string => {
|
||||||
|
if (!content || content.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === 'content' && item.content?.text) {
|
||||||
|
parts.push(String(item.content.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTodoLines = (text: string): string[] => {
|
||||||
|
if (!text) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return lines.map((line) => {
|
||||||
|
const idx = line.indexOf('] ');
|
||||||
|
return idx >= 0 ? line.slice(idx + 2).trim() : line;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameOrSupplement = (
|
||||||
|
prevText: string,
|
||||||
|
nextText: string,
|
||||||
|
): { same: boolean; supplement: boolean } => {
|
||||||
|
const prev = normalizeTodoLines(prevText);
|
||||||
|
const next = normalizeTodoLines(nextText);
|
||||||
|
if (prev.length === next.length) {
|
||||||
|
const same = prev.every((l, i) => l === next[i]);
|
||||||
|
if (same) {
|
||||||
|
return { same: true, supplement: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// supplement = prev set is subset of next set
|
||||||
|
const setNext = new Set(next);
|
||||||
|
const subset = prev.every((l) => setNext.has(l));
|
||||||
|
return { same: false, supplement: subset };
|
||||||
|
};
|
||||||
|
|
||||||
const safeTitle = (title: unknown): string => {
|
const safeTitle = (title: unknown): string => {
|
||||||
if (typeof title === 'string') {
|
if (typeof title === 'string') {
|
||||||
return title;
|
return title;
|
||||||
@@ -44,6 +108,49 @@ export const useToolCalls = () => {
|
|||||||
newText: item.newText,
|
newText: item.newText,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 合并策略:对于 todo_write + mergeable 标题(Updated Plan/Update Todos),
|
||||||
|
// 如果与最近一条同类卡片相同或是补充,则合并更新而不是新增。
|
||||||
|
if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) {
|
||||||
|
const nextText = extractText(content);
|
||||||
|
// 找最近一条 todo_write + 可合并标题 的卡片
|
||||||
|
let lastId: string | null = null;
|
||||||
|
let lastText = '';
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
for (const tc of newMap.values()) {
|
||||||
|
if (
|
||||||
|
isTodoWrite(tc.kind) &&
|
||||||
|
isTodoTitleMergeable(tc.title) &&
|
||||||
|
typeof tc.timestamp === 'number' &&
|
||||||
|
tc.timestamp >= lastTimestamp
|
||||||
|
) {
|
||||||
|
lastId = tc.toolCallId;
|
||||||
|
lastText = extractText(tc.content);
|
||||||
|
lastTimestamp = tc.timestamp || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastId) {
|
||||||
|
const cmp = isSameOrSupplement(lastText, nextText);
|
||||||
|
if (cmp.same) {
|
||||||
|
// 完全相同:忽略本次新增
|
||||||
|
return newMap;
|
||||||
|
}
|
||||||
|
if (cmp.supplement) {
|
||||||
|
// 补充:替换内容到上一条(使用更新语义)
|
||||||
|
const prev = newMap.get(lastId);
|
||||||
|
if (prev) {
|
||||||
|
newMap.set(lastId, {
|
||||||
|
...prev,
|
||||||
|
content, // 覆盖(不追加)
|
||||||
|
status: update.status || prev.status,
|
||||||
|
timestamp: update.timestamp || Date.now(),
|
||||||
|
});
|
||||||
|
return newMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newMap.set(update.toolCallId, {
|
newMap.set(update.toolCallId, {
|
||||||
toolCallId: update.toolCallId,
|
toolCallId: update.toolCallId,
|
||||||
kind: update.kind || 'other',
|
kind: update.kind || 'other',
|
||||||
@@ -52,6 +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(), // 添加时间戳
|
||||||
});
|
});
|
||||||
} else if (update.type === 'tool_call_update') {
|
} else if (update.type === 'tool_call_update') {
|
||||||
const updatedContent = update.content
|
const updatedContent = update.content
|
||||||
@@ -65,9 +173,25 @@ export const useToolCalls = () => {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const mergedContent = updatedContent
|
// 默认行为是追加;但对于 todo_write + 可合并标题,使用替换避免堆叠重复
|
||||||
? [...(existing.content || []), ...updatedContent]
|
let mergedContent = existing.content;
|
||||||
: existing.content;
|
if (updatedContent) {
|
||||||
|
if (
|
||||||
|
isTodoWrite(update.kind || existing.kind) &&
|
||||||
|
(isTodoTitleMergeable(update.title) ||
|
||||||
|
isTodoTitleMergeable(existing.title))
|
||||||
|
) {
|
||||||
|
mergedContent = updatedContent; // 覆盖
|
||||||
|
} else {
|
||||||
|
mergedContent = [...(existing.content || []), ...updatedContent];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If tool call has just completed/failed, bump timestamp to now for correct ordering
|
||||||
|
const isFinal =
|
||||||
|
update.status === 'completed' || update.status === 'failed';
|
||||||
|
const nextTimestamp = isFinal
|
||||||
|
? Date.now()
|
||||||
|
: update.timestamp || existing.timestamp || Date.now();
|
||||||
|
|
||||||
newMap.set(update.toolCallId, {
|
newMap.set(update.toolCallId, {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -76,6 +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, // 更新时间戳(完成/失败时以完成时间为准)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newMap.set(update.toolCallId, {
|
newMap.set(update.toolCallId, {
|
||||||
@@ -86,6 +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(), // 添加时间戳
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,12 @@ interface UseWebViewMessagesProps {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
startStreaming: () => void;
|
startStreaming: (timestamp?: number) => void;
|
||||||
appendStreamChunk: (chunk: string) => void;
|
appendStreamChunk: (chunk: string) => void;
|
||||||
endStreaming: () => void;
|
endStreaming: () => void;
|
||||||
|
breakAssistantSegment: () => void;
|
||||||
|
appendThinkingChunk: (chunk: string) => void;
|
||||||
|
clearThinking: () => void;
|
||||||
clearWaitingForResponse: () => void;
|
clearWaitingForResponse: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +119,38 @@ export const useWebViewMessages = ({
|
|||||||
handlePermissionRequest,
|
handlePermissionRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track last "Updated Plan" snapshot toolcall to support merge/dedupe
|
||||||
|
const lastPlanSnapshotRef = useRef<{
|
||||||
|
id: string;
|
||||||
|
text: string; // joined lines
|
||||||
|
lines: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const buildPlanLines = (entries: PlanEntry[]): string[] =>
|
||||||
|
entries.map((e) => {
|
||||||
|
const mark =
|
||||||
|
e.status === 'completed' ? 'x' : e.status === 'in_progress' ? '-' : ' ';
|
||||||
|
return `- [${mark}] ${e.content}`.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSupplementOf = (
|
||||||
|
prevLines: string[],
|
||||||
|
nextLines: string[],
|
||||||
|
): boolean => {
|
||||||
|
// 认为“补充” = 旧内容的文本集合(忽略状态)被新内容包含
|
||||||
|
const key = (line: string) => {
|
||||||
|
const idx = line.indexOf('] ');
|
||||||
|
return idx >= 0 ? line.slice(idx + 2).trim() : line.trim();
|
||||||
|
};
|
||||||
|
const nextSet = new Set(nextLines.map(key));
|
||||||
|
for (const pl of prevLines) {
|
||||||
|
if (!nextSet.has(key(pl))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// Update refs
|
// Update refs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlersRef.current = {
|
handlersRef.current = {
|
||||||
@@ -202,12 +237,42 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'message': {
|
case 'message': {
|
||||||
handlers.messageHandling.addMessage(message.data);
|
const msg = message.data as {
|
||||||
|
role?: 'user' | 'assistant' | 'thinking';
|
||||||
|
content?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
};
|
||||||
|
handlers.messageHandling.addMessage(
|
||||||
|
msg as unknown as Parameters<
|
||||||
|
typeof handlers.messageHandling.addMessage
|
||||||
|
>[0],
|
||||||
|
);
|
||||||
|
// Robustness: if an assistant message arrives outside the normal stream
|
||||||
|
// pipeline (no explicit streamEnd), ensure we clear streaming/waiting states
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
try {
|
||||||
|
handlers.messageHandling.endStreaming();
|
||||||
|
} catch (err) {
|
||||||
|
// no-op: stream might not have been started
|
||||||
|
console.warn('[PanelManager] Failed to end streaming:', err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handlers.messageHandling.clearWaitingForResponse();
|
||||||
|
} catch (err) {
|
||||||
|
// no-op: already cleared
|
||||||
|
console.warn(
|
||||||
|
'[PanelManager] Failed to clear waiting for response:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'streamStart':
|
case 'streamStart':
|
||||||
handlers.messageHandling.startStreaming();
|
handlers.messageHandling.startStreaming(
|
||||||
|
(message.data as { timestamp?: number } | undefined)?.timestamp,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'streamChunk': {
|
case 'streamChunk': {
|
||||||
@@ -216,17 +281,14 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'thoughtChunk': {
|
case 'thoughtChunk': {
|
||||||
const thinkingMessage = {
|
const chunk = message.data.content || message.data.chunk || '';
|
||||||
role: 'thinking' as const,
|
handlers.messageHandling.appendThinkingChunk(chunk);
|
||||||
content: message.data.content || message.data.chunk || '',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
handlers.messageHandling.addMessage(thinkingMessage);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'streamEnd':
|
case 'streamEnd':
|
||||||
handlers.messageHandling.endStreaming();
|
handlers.messageHandling.endStreaming();
|
||||||
|
handlers.messageHandling.clearThinking();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -276,13 +338,76 @@ export const useWebViewMessages = ({
|
|||||||
content: permToolCall.content as ToolCallUpdate['content'],
|
content: permToolCall.content as ToolCallUpdate['content'],
|
||||||
locations: permToolCall.locations,
|
locations: permToolCall.locations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Split assistant stream so subsequent chunks start a new assistant message
|
||||||
|
handlers.messageHandling.breakAssistantSegment();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'plan':
|
case 'plan':
|
||||||
if (message.data.entries && Array.isArray(message.data.entries)) {
|
if (message.data.entries && Array.isArray(message.data.entries)) {
|
||||||
handlers.setPlanEntries(message.data.entries as PlanEntry[]);
|
const entries = message.data.entries as PlanEntry[];
|
||||||
|
handlers.setPlanEntries(entries);
|
||||||
|
|
||||||
|
// 生成新的快照文本
|
||||||
|
const lines = buildPlanLines(entries);
|
||||||
|
const text = lines.join('\n');
|
||||||
|
const prev = lastPlanSnapshotRef.current;
|
||||||
|
|
||||||
|
// 1) 完全相同 -> 跳过
|
||||||
|
if (prev && prev.text === text) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ts = Date.now();
|
||||||
|
|
||||||
|
// 2) 补充或状态更新 -> 合并到上一条(使用 tool_call_update 覆盖内容)
|
||||||
|
if (prev && isSupplementOf(prev.lines, lines)) {
|
||||||
|
handlers.handleToolCallUpdate({
|
||||||
|
type: 'tool_call_update',
|
||||||
|
toolCallId: prev.id,
|
||||||
|
kind: 'todo_write',
|
||||||
|
title: 'Updated Plan',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'content',
|
||||||
|
content: { type: 'text', text },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
lastPlanSnapshotRef.current = { id: prev.id, text, lines };
|
||||||
|
} else {
|
||||||
|
// 3) 其他情况 -> 新增一条历史卡片
|
||||||
|
const toolCallId = `plan-snapshot-${ts}`;
|
||||||
|
handlers.handleToolCallUpdate({
|
||||||
|
type: 'tool_call',
|
||||||
|
toolCallId,
|
||||||
|
kind: 'todo_write',
|
||||||
|
title: 'Updated Plan',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'content',
|
||||||
|
content: { type: 'text', text },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
lastPlanSnapshotRef.current = { id: toolCallId, text, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割助手消息段,保持渲染块独立
|
||||||
|
handlers.messageHandling.breakAssistantSegment?.();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[useWebViewMessages] failed to push/merge plan snapshot toolcall:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -293,6 +418,15 @@ export const useWebViewMessages = ({
|
|||||||
toolCallData.type = toolCallData.sessionUpdate;
|
toolCallData.type = toolCallData.sessionUpdate;
|
||||||
}
|
}
|
||||||
handlers.handleToolCallUpdate(toolCallData);
|
handlers.handleToolCallUpdate(toolCallData);
|
||||||
|
// Split assistant stream at tool boundaries similar to Claude/GPT rhythm
|
||||||
|
const status = (toolCallData.status || '').toString();
|
||||||
|
const isStart = toolCallData.type === 'tool_call';
|
||||||
|
const isFinalUpdate =
|
||||||
|
toolCallData.type === 'tool_call_update' &&
|
||||||
|
(status === 'completed' || status === 'failed');
|
||||||
|
if (isStart || isFinalUpdate) {
|
||||||
|
handlers.messageHandling.breakAssistantSegment();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +477,7 @@ export const useWebViewMessages = ({
|
|||||||
}
|
}
|
||||||
handlers.clearToolCalls();
|
handlers.clearToolCalls();
|
||||||
handlers.setPlanEntries([]);
|
handlers.setPlanEntries([]);
|
||||||
|
lastPlanSnapshotRef.current = null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'conversationCleared':
|
case 'conversationCleared':
|
||||||
@@ -352,6 +487,7 @@ export const useWebViewMessages = ({
|
|||||||
handlers.sessionManagement.setCurrentSessionTitle(
|
handlers.sessionManagement.setCurrentSessionTitle(
|
||||||
'Past Conversations',
|
'Past Conversations',
|
||||||
);
|
);
|
||||||
|
lastPlanSnapshotRef.current = null;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'sessionTitleUpdated': {
|
case 'sessionTitleUpdated': {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface ToolCallUpdate {
|
|||||||
path: string;
|
path: string;
|
||||||
line?: number | null;
|
line?: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
timestamp?: number; // 添加时间戳字段用于消息排序
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*
|
||||||
|
* Minimal line-diff utility for webview previews.
|
||||||
|
*
|
||||||
|
* This is a lightweight LCS-based algorithm to compute add/remove operations
|
||||||
|
* between two texts. It intentionally avoids heavy dependencies and is
|
||||||
|
* sufficient for rendering a compact preview inside the chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DiffOp =
|
||||||
|
| { type: 'add'; line: string; newIndex: number }
|
||||||
|
| { type: 'remove'; line: string; oldIndex: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a minimal line-diff (added/removed only).
|
||||||
|
* - Equal lines are omitted from output by design (we only preview changes).
|
||||||
|
* - Order of operations follows the new text progression so the preview feels natural.
|
||||||
|
*/
|
||||||
|
export function computeLineDiff(
|
||||||
|
oldText: string | null | undefined,
|
||||||
|
newText: string | undefined,
|
||||||
|
): DiffOp[] {
|
||||||
|
const a = (oldText || '').split('\n');
|
||||||
|
const b = (newText || '').split('\n');
|
||||||
|
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
|
||||||
|
// Build LCS DP table
|
||||||
|
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
new Array(m + 1).fill(0),
|
||||||
|
);
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk to produce operations
|
||||||
|
const ops: DiffOp[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
// remove a[i]
|
||||||
|
ops.push({ type: 'remove', line: a[i], oldIndex: i });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// add b[j]
|
||||||
|
ops.push({ type: 'add', line: b[j], newIndex: j });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining tails
|
||||||
|
while (i < n) {
|
||||||
|
ops.push({ type: 'remove', line: a[i], oldIndex: i });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (j < m) {
|
||||||
|
ops.push({ type: 'add', line: b[j], newIndex: j });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a long list of operations for preview purposes.
|
||||||
|
* Keeps first `head` and last `tail` operations, inserting a gap marker.
|
||||||
|
*/
|
||||||
|
export function truncateOps<T>(
|
||||||
|
ops: T[],
|
||||||
|
head = 120,
|
||||||
|
tail = 80,
|
||||||
|
): { items: T[]; truncated: boolean; omitted: number } {
|
||||||
|
if (ops.length <= head + tail) {
|
||||||
|
return { items: ops, truncated: false, omitted: 0 };
|
||||||
|
}
|
||||||
|
const items = [...ops.slice(0, head), ...ops.slice(-tail)];
|
||||||
|
return { items, truncated: true, omitted: ops.length - head - tail };
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ module.exports = {
|
|||||||
'./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}',
|
||||||
'./src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}',
|
'./src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./src/webview/components/InProgressToolCall.tsx',
|
||||||
'./src/webview/components/MessageContent.tsx',
|
'./src/webview/components/MessageContent.tsx',
|
||||||
'./src/webview/components/InfoBanner.tsx',
|
'./src/webview/components/InfoBanner.tsx',
|
||||||
'./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',
|
||||||
// 当需要在更多组件中使用Tailwind时,可以逐步添加路径
|
// 当需要在更多组件中使用Tailwind时,可以逐步添加路径
|
||||||
// "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}",
|
// "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}",
|
||||||
// "./src/webview/pages/**/*.{js,jsx,ts,tsx}",
|
// "./src/webview/pages/**/*.{js,jsx,ts,tsx}",
|
||||||
|
|||||||
Reference in New Issue
Block a user