feat(vscode-ide-companion): 改进消息排序和显示逻辑

- 添加时间戳支持,确保消息按时间顺序排列
- 更新工具调用处理逻辑,自动添加和保留时间戳
- 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染
- 优化完成的工具调用显示,修复显示顺序问题
- 调整进行中的工具调用显示,统一到消息流中展示
- 移除重复的计划展示逻辑,避免最新块重复出现
- 重构消息处理和渲染代码,提高可维护性
This commit is contained in:
yiliang114
2025-11-28 09:55:06 +08:00
parent dc340daf8b
commit 9cc48f12da
44 changed files with 2445 additions and 767 deletions

View File

@@ -4,7 +4,7 @@
* 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 { useSessionManagement } from './hooks/session/useSessionManagement.js';
import { useFileContext } from './hooks/file/useFileContext.js';
@@ -16,16 +16,15 @@ import type {
PermissionOption,
ToolCall as PermissionToolCall,
} 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 { ToolCall } from './components/ToolCall.js';
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
import { InProgressToolCall } from './components/InProgressToolCall.js';
import { EmptyState } from './components/EmptyState.js';
import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js';
import {
CompletionMenu,
type CompletionItem,
} from './components/CompletionMenu.js';
import type { PlanEntry } from './components/PlanDisplay.js';
import { type CompletionItem } from './components/CompletionMenu.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { SaveSessionDialog } from './components/SaveSessionDialog.js';
import { InfoBanner } from './components/InfoBanner.js';
@@ -34,7 +33,6 @@ import {
UserMessage,
AssistantMessage,
ThinkingMessage,
StreamingMessage,
WaitingMessage,
} from './components/messages/index.js';
import { InputForm } from './components/InputForm.js';
@@ -87,7 +85,9 @@ export const App: React.FC = () => {
description: file.description,
type: 'file' as const,
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;
} else {
// Handle slash commands
const commands: CompletionItem[] = [
{
id: 'login',
@@ -124,18 +137,25 @@ export const App: React.FC = () => {
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
const { handleSubmit } = useMessageSubmit({
vscode,
const handleSubmit = useMessageSubmit({
inputText,
setInputText,
messageHandling,
fileContext,
vscode,
inputFieldRef,
isStreaming: messageHandling.isStreaming,
fileContext,
messageHandling,
});
// WebView messages
// Message handling
useWebViewMessages({
sessionManagement,
fileContext,
@@ -143,22 +163,16 @@ export const App: React.FC = () => {
handleToolCallUpdate,
clearToolCalls,
setPlanEntries,
handlePermissionRequest: React.useCallback(
(request: {
options: PermissionOption[];
toolCall: PermissionToolCall;
}) => {
setPermissionRequest(request);
},
[],
),
handlePermissionRequest: setPermissionRequest,
inputFieldRef,
setInputText,
});
// Permission handling
const handlePermissionResponse = React.useCallback(
// Handle permission response
const handlePermissionResponse = useCallback(
(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({
type: 'permissionResponse',
data: { optionId },
@@ -168,182 +182,153 @@ export const App: React.FC = () => {
[vscode],
);
// Completion selection
const handleCompletionSelect = React.useCallback(
// Handle completion selection
const handleCompletionSelect = useCallback(
(item: CompletionItem) => {
if (!inputFieldRef.current) {
// Handle completion selection by inserting the value into the input field
const inputElement = inputFieldRef.current;
if (!inputElement) {
return;
}
const inputElement = inputFieldRef.current;
const currentText = inputElement.textContent || '';
// Ignore info items (placeholders like "Searching files…")
if (item.type === 'info') {
completion.closeCompletion();
return;
}
// Slash commands can execute immediately
if (item.type === 'command') {
if (item.label === '/login') {
inputElement.textContent = '';
setInputText('');
const command = (item.label || '').trim();
if (command === '/login') {
vscode.postMessage({ type: 'login', data: {} });
completion.closeCompletion();
vscode.postMessage({
type: 'login',
data: {},
});
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, vscode, fileContext],
[completion, inputFieldRef, setInputText, fileContext, vscode],
);
// Attach context (Cmd/Ctrl + /)
const handleAttachContextClick = React.useCallback(async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
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();
// Handle save session
const handleSaveSession = useCallback(
async (tag: string) => {
if (!sessionManagement.currentSessionId) {
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleAttachContextClick]);
try {
vscode.postMessage({
type: 'saveSession',
data: {
sessionId: sessionManagement.currentSessionId,
tag,
},
});
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messageHandling.messages, messageHandling.currentStreamContent]);
// Assume success for now, as we don't get a response
sessionManagement.setSavedSessionTags((prev) => [...prev, tag]);
setShowSaveDialog(false);
} catch (error) {
console.error('[App] Error saving session:', error);
}
},
[sessionManagement, vscode],
);
// Load sessions on mount
useEffect(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
// Handle attach context click
const handleAttachContextClick = useCallback(() => {
// Open native file picker (different from '@' completion which searches workspace files)
vscode.postMessage({
type: 'attachFile',
data: {},
});
}, [vscode]);
// Request active editor on mount
useEffect(() => {
fileContext.requestActiveEditor();
}, [fileContext]);
// Toggle edit mode
const handleToggleEditMode = () => {
// Handle toggle edit mode
const handleToggleEditMode = useCallback(() => {
setEditMode((prev) => {
if (prev === 'ask') {
return 'auto';
@@ -353,8 +338,9 @@ export const App: React.FC = () => {
}
return 'ask';
});
};
}, []);
// Handle toggle thinking
const handleToggleThinking = () => {
setThinkingEnabled((prev) => !prev);
};
@@ -389,66 +375,119 @@ export const App: React.FC = () => {
/>
<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)' }}
>
{!hasContent ? (
<EmptyState />
) : (
<>
{messageHandling.messages.map((msg, index) => {
const handleFileClick = (path: string) => {
vscode.postMessage({
type: 'openFile',
data: { path },
});
};
{/* 创建统一的消息数组,包含所有类型的消息和工具调用 */}
{(() => {
// 普通消息
const regularMessages = messageHandling.messages.map((msg) => ({
type: 'message' as const,
data: msg,
timestamp: msg.timestamp,
}));
if (msg.role === 'thinking') {
return (
<ThinkingMessage
key={index}
content={msg.content}
timestamp={msg.timestamp}
onFileClick={handleFileClick}
/>
);
}
// 进行中的工具调用
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
type: 'in-progress-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
if (msg.role === 'user') {
return (
<UserMessage
key={index}
content={msg.content}
timestamp={msg.timestamp}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
/>
);
}
// 完成的工具调用
const completedTools = completedToolCalls
.filter(hasToolCallOutput)
.map((toolCall) => ({
type: 'completed-tool-call' as const,
data: toolCall,
timestamp: toolCall.timestamp || Date.now(),
}));
return (
<AssistantMessage
key={index}
content={msg.content}
timestamp={msg.timestamp}
onFileClick={handleFileClick}
/>
);
})}
// 合并并按时间戳排序,确保消息与工具调用穿插显示
const allMessages = [
...regularMessages,
...inProgressTools,
...completedTools,
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
{inProgressToolCalls.map((toolCall) => (
<InProgressToolCall
key={toolCall.toolCallId}
toolCall={toolCall}
/>
))}
console.log('[App] allMessages:', allMessages);
{completedToolCalls.filter(hasToolCallOutput).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
return allMessages.map((item, index) => {
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.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} />
</>
)}
@@ -497,7 +523,7 @@ export const App: React.FC = () => {
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmit}
onSubmit={handleSubmit.handleSubmit}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}
@@ -537,12 +563,15 @@ export const App: React.FC = () => {
}}
onAttachContext={handleAttachContextClick}
completionIsOpen={completion.isOpen}
completionItems={completion.items}
onCompletionSelect={handleCompletionSelect}
onCompletionClose={completion.closeCompletion}
/>
<SaveSessionDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
onSave={sessionManagement.handleSaveSession}
onSave={handleSaveSession}
existingTags={sessionManagement.savedSessionTags}
/>
@@ -556,14 +585,7 @@ export const App: React.FC = () => {
/>
)}
{completion.isOpen && completion.items.length > 0 && (
<CompletionMenu
items={completion.items}
position={completion.position}
onSelect={handleCompletionSelect}
onClose={completion.closeCompletion}
/>
)}
{/* Claude-style dropdown is rendered inside InputForm for proper anchoring */}
</div>
);
};