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

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

View File

@@ -1,212 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { useCompletionTrigger } from './useCompletionTrigger';
// Mock CompletionItem type
interface CompletionItem {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
type: 'file' | 'symbol' | 'command' | 'variable';
value?: unknown;
}
describe('useCompletionTrigger', () => {
let mockInputRef: React.RefObject<HTMLDivElement>;
let mockGetCompletionItems: (
trigger: '@' | '/',
query: string,
) => Promise<CompletionItem[]>;
beforeEach(() => {
mockInputRef = {
current: document.createElement('div'),
};
mockGetCompletionItems = jest.fn();
});
afterEach(() => {
jest.clearAllTimers();
});
it('should trigger completion when @ is typed at word boundary', async () => {
mockGetCompletionItems.mockResolvedValue([
{ id: '1', label: 'test.txt', type: 'file' },
]);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
// Mock window.getSelection to return a valid range
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.isOpen).toBe(true);
expect(result.current.triggerChar).toBe('@');
expect(mockGetCompletionItems).toHaveBeenCalledWith('@', '');
});
it('should show loading state initially', async () => {
// Simulate slow file loading
mockGetCompletionItems.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
100,
),
),
);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations but not for the slow promise
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should show loading state immediately
expect(result.current.isOpen).toBe(true);
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('loading');
});
it('should timeout if loading takes too long', async () => {
// Simulate very slow file loading
mockGetCompletionItems.mockImplementation(
() =>
new Promise(
(resolve) =>
setTimeout(
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
10000,
), // 10 seconds
),
);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should show loading state initially
expect(result.current.isOpen).toBe(true);
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('loading');
// Wait for timeout (5 seconds)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5100)); // 5.1 seconds
});
// Should show timeout message
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('timeout');
expect(result.current.items[0].label).toBe('Timeout');
});
it('should close completion when cursor moves away from trigger', async () => {
mockGetCompletionItems.mockResolvedValue([
{ id: '1', label: 'test.txt', type: 'file' },
]);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event to open completion
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.isOpen).toBe(true);
// Simulate moving cursor away (typing space after @)
mockInputRef.current.textContent = '@ ';
// Trigger input event to close completion
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should close completion when query contains space
expect(result.current.isOpen).toBe(false);
});
});

View File

@@ -5,7 +5,7 @@
*/
import type { RefObject } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import type { CompletionItem } from '../components/CompletionMenu.js';
interface CompletionTriggerState {
@@ -27,6 +27,26 @@ export function useCompletionTrigger(
query: string,
) => Promise<CompletionItem[]>,
) {
// Show immediate loading and provide a timeout fallback for slow sources
const LOADING_ITEM = useMemo<CompletionItem>(
() => ({
id: 'loading',
label: 'Loading…',
type: 'info',
}),
[],
);
const TIMEOUT_ITEM = useMemo<CompletionItem>(
() => ({
id: 'timeout',
label: 'Timeout',
type: 'info',
}),
[],
);
const TIMEOUT_MS = 5000;
const [state, setState] = useState<CompletionTriggerState>({
isOpen: false,
triggerChar: null,
@@ -35,7 +55,15 @@ export function useCompletionTrigger(
items: [],
});
// Timer for loading timeout
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeCompletion = useCallback(() => {
// Clear pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setState({
isOpen: false,
triggerChar: null,
@@ -51,16 +79,56 @@ export function useCompletionTrigger(
query: string,
position: { top: number; left: number },
) => {
const items = await getCompletionItems(trigger, query);
// Clear previous timeout if any
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Open immediately with a loading placeholder
setState({
isOpen: true,
triggerChar: trigger,
query,
position,
items,
items: [LOADING_ITEM],
});
// Schedule a timeout fallback if loading takes too long
timeoutRef.current = setTimeout(() => {
setState((prev) => {
// Only show timeout if still open and still for the same request
if (
prev.isOpen &&
prev.triggerChar === trigger &&
prev.query === query &&
prev.items.length > 0 &&
prev.items[0]?.id === 'loading'
) {
return { ...prev, items: [TIMEOUT_ITEM] };
}
return prev;
});
}, TIMEOUT_MS);
const items = await getCompletionItems(trigger, query);
// Clear timeout on success
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setState((prev) => ({
...prev,
isOpen: true,
triggerChar: trigger,
query,
position,
items,
}));
},
[getCompletionItems],
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
);
const refreshCompletion = useCallback(async () => {

View File

@@ -1,93 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { useToolCalls } from './useToolCalls';
import type { ToolCallUpdate } from '../types/toolCall.js';
describe('useToolCalls', () => {
it('should add timestamp when creating tool call', () => {
const { result } = renderHook(() => useToolCalls());
const toolCallUpdate: ToolCallUpdate = {
type: 'tool_call',
toolCallId: 'test-1',
kind: 'read',
title: 'Read file',
status: 'pending',
};
act(() => {
result.current.handleToolCallUpdate(toolCallUpdate);
});
const toolCalls = Array.from(result.current.toolCalls.values());
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].timestamp).toBeDefined();
expect(typeof toolCalls[0].timestamp).toBe('number');
});
it('should preserve timestamp when updating tool call', () => {
const { result } = renderHook(() => useToolCalls());
const timestamp = Date.now() - 1000; // 1 second ago
// Create tool call with specific timestamp
const toolCallUpdate: ToolCallUpdate = {
type: 'tool_call',
toolCallId: 'test-1',
kind: 'read',
title: 'Read file',
status: 'pending',
timestamp,
};
act(() => {
result.current.handleToolCallUpdate(toolCallUpdate);
});
// Update tool call without timestamp
const toolCallUpdate2: ToolCallUpdate = {
type: 'tool_call_update',
toolCallId: 'test-1',
status: 'completed',
};
act(() => {
result.current.handleToolCallUpdate(toolCallUpdate2);
});
const toolCalls = Array.from(result.current.toolCalls.values());
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].timestamp).toBe(timestamp);
});
it('should use current time as default timestamp', () => {
const { result } = renderHook(() => useToolCalls());
const before = Date.now();
const toolCallUpdate: ToolCallUpdate = {
type: 'tool_call',
toolCallId: 'test-1',
kind: 'read',
title: 'Read file',
status: 'pending',
// No timestamp provided
};
act(() => {
result.current.handleToolCallUpdate(toolCallUpdate);
});
const after = Date.now();
const toolCalls = Array.from(result.current.toolCalls.values());
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].timestamp).toBeGreaterThanOrEqual(before);
expect(toolCalls[0].timestamp).toBeLessThanOrEqual(after);
});
});

View File

@@ -108,11 +108,11 @@ export const useToolCalls = () => {
newText: item.newText,
}));
// 合并策略:对于 todo_write + mergeable 标题(Updated Plan/Update Todos
// 如果与最近一条同类卡片相同或是补充,则合并更新而不是新增。
// Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos),
// if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new.
if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) {
const nextText = extractText(content);
// 找最近一条 todo_write + 可合并标题 的卡片
// Find the most recent card with todo_write + mergeable title
let lastId: string | null = null;
let lastText = '';
let lastTimestamp = 0;
@@ -132,16 +132,16 @@ export const useToolCalls = () => {
if (lastId) {
const cmp = isSameOrSupplement(lastText, nextText);
if (cmp.same) {
// 完全相同:忽略本次新增
// Completely identical: Ignore this addition
return newMap;
}
if (cmp.supplement) {
// 补充:替换内容到上一条(使用更新语义)
// Supplement: Replace content to the previous item (using update semantics)
const prev = newMap.get(lastId);
if (prev) {
newMap.set(lastId, {
...prev,
content, // 覆盖(不追加)
content, // Override (do not append)
status: update.status || prev.status,
timestamp: update.timestamp || Date.now(),
});
@@ -159,7 +159,7 @@ export const useToolCalls = () => {
rawInput: update.rawInput as string | object | undefined,
content,
locations: update.locations,
timestamp: update.timestamp || Date.now(), // 添加时间戳
timestamp: update.timestamp || Date.now(), // Add timestamp
});
} else if (update.type === 'tool_call_update') {
const updatedContent = update.content
@@ -173,7 +173,7 @@ export const useToolCalls = () => {
: undefined;
if (existing) {
// 默认行为是追加;但对于 todo_write + 可合并标题,使用替换避免堆叠重复
// Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates
let mergedContent = existing.content;
if (updatedContent) {
if (
@@ -181,7 +181,7 @@ export const useToolCalls = () => {
(isTodoTitleMergeable(update.title) ||
isTodoTitleMergeable(existing.title))
) {
mergedContent = updatedContent; // 覆盖
mergedContent = updatedContent; // Override
} else {
mergedContent = [...(existing.content || []), ...updatedContent];
}
@@ -200,7 +200,7 @@ export const useToolCalls = () => {
...(update.status && { status: update.status }),
content: mergedContent,
...(update.locations && { locations: update.locations }),
timestamp: nextTimestamp, // 更新时间戳(完成/失败时以完成时间为准)
timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed)
});
} else {
newMap.set(update.toolCallId, {
@@ -211,7 +211,7 @@ export const useToolCalls = () => {
rawInput: update.rawInput as string | object | undefined,
content: updatedContent,
locations: update.locations,
timestamp: update.timestamp || Date.now(), // 添加时间戳
timestamp: update.timestamp || Date.now(), // Add timestamp
});
}
}

View File

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