mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 17:27:54 +00:00
refactor(vscode): 重构消息排序和展示逻辑
- 移除旧的消息排序改进总结文档 - 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序 - 优化工具调用处理流程,添加时间戳支持 - 改进会话保存机制,直接使用SessionManager保存检查点 - 重构部分组件以提高可维护性
This commit is contained in:
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCompletionTrigger } from './useCompletionTrigger';
|
||||
|
||||
// Mock CompletionItem type
|
||||
interface CompletionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
type: 'file' | 'symbol' | 'command' | 'variable';
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
describe('useCompletionTrigger', () => {
|
||||
let mockInputRef: React.RefObject<HTMLDivElement>;
|
||||
let mockGetCompletionItems: (
|
||||
trigger: '@' | '/',
|
||||
query: string,
|
||||
) => Promise<CompletionItem[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInputRef = {
|
||||
current: document.createElement('div'),
|
||||
};
|
||||
|
||||
mockGetCompletionItems = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
it('should trigger completion when @ is typed at word boundary', async () => {
|
||||
mockGetCompletionItems.mockResolvedValue([
|
||||
{ id: '1', label: 'test.txt', type: 'file' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
||||
);
|
||||
|
||||
// Simulate typing @ at the beginning
|
||||
mockInputRef.current.textContent = '@';
|
||||
|
||||
// Mock window.getSelection to return a valid range
|
||||
const mockRange = {
|
||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
||||
};
|
||||
|
||||
window.getSelection = jest.fn().mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: () => mockRange,
|
||||
} as unknown as Selection);
|
||||
|
||||
// Trigger input event
|
||||
await act(async () => {
|
||||
const event = new Event('input', { bubbles: true });
|
||||
mockInputRef.current.dispatchEvent(event);
|
||||
// Wait for async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.triggerChar).toBe('@');
|
||||
expect(mockGetCompletionItems).toHaveBeenCalledWith('@', '');
|
||||
});
|
||||
|
||||
it('should show loading state initially', async () => {
|
||||
// Simulate slow file loading
|
||||
mockGetCompletionItems.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
|
||||
100,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
||||
);
|
||||
|
||||
// Simulate typing @ at the beginning
|
||||
mockInputRef.current.textContent = '@';
|
||||
|
||||
const mockRange = {
|
||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
||||
};
|
||||
|
||||
window.getSelection = jest.fn().mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: () => mockRange,
|
||||
} as unknown as Selection);
|
||||
|
||||
// Trigger input event
|
||||
await act(async () => {
|
||||
const event = new Event('input', { bubbles: true });
|
||||
mockInputRef.current.dispatchEvent(event);
|
||||
// Wait for async operations but not for the slow promise
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Should show loading state immediately
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
expect(result.current.items[0].id).toBe('loading');
|
||||
});
|
||||
|
||||
it('should timeout if loading takes too long', async () => {
|
||||
// Simulate very slow file loading
|
||||
mockGetCompletionItems.mockImplementation(
|
||||
() =>
|
||||
new Promise(
|
||||
(resolve) =>
|
||||
setTimeout(
|
||||
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
|
||||
10000,
|
||||
), // 10 seconds
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
||||
);
|
||||
|
||||
// Simulate typing @ at the beginning
|
||||
mockInputRef.current.textContent = '@';
|
||||
|
||||
const mockRange = {
|
||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
||||
};
|
||||
|
||||
window.getSelection = jest.fn().mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: () => mockRange,
|
||||
} as unknown as Selection);
|
||||
|
||||
// Trigger input event
|
||||
await act(async () => {
|
||||
const event = new Event('input', { bubbles: true });
|
||||
mockInputRef.current.dispatchEvent(event);
|
||||
// Wait for async operations
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Should show loading state initially
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
expect(result.current.items[0].id).toBe('loading');
|
||||
|
||||
// Wait for timeout (5 seconds)
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5100)); // 5.1 seconds
|
||||
});
|
||||
|
||||
// Should show timeout message
|
||||
expect(result.current.items).toHaveLength(1);
|
||||
expect(result.current.items[0].id).toBe('timeout');
|
||||
expect(result.current.items[0].label).toBe('Timeout');
|
||||
});
|
||||
|
||||
it('should close completion when cursor moves away from trigger', async () => {
|
||||
mockGetCompletionItems.mockResolvedValue([
|
||||
{ id: '1', label: 'test.txt', type: 'file' },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
|
||||
);
|
||||
|
||||
// Simulate typing @ at the beginning
|
||||
mockInputRef.current.textContent = '@';
|
||||
|
||||
const mockRange = {
|
||||
getBoundingClientRect: () => ({ top: 100, left: 50 }),
|
||||
};
|
||||
|
||||
window.getSelection = jest.fn().mockReturnValue({
|
||||
rangeCount: 1,
|
||||
getRangeAt: () => mockRange,
|
||||
} as unknown as Selection);
|
||||
|
||||
// Trigger input event to open completion
|
||||
await act(async () => {
|
||||
const event = new Event('input', { bubbles: true });
|
||||
mockInputRef.current.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
|
||||
// Simulate moving cursor away (typing space after @)
|
||||
mockInputRef.current.textContent = '@ ';
|
||||
|
||||
// Trigger input event to close completion
|
||||
await act(async () => {
|
||||
const event = new Event('input', { bubbles: true });
|
||||
mockInputRef.current.dispatchEvent(event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
// Should close completion when query contains space
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
import { 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 () => {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useToolCalls } from './useToolCalls';
|
||||
import type { ToolCallUpdate } from '../types/toolCall.js';
|
||||
|
||||
describe('useToolCalls', () => {
|
||||
it('should add timestamp when creating tool call', () => {
|
||||
const { result } = renderHook(() => useToolCalls());
|
||||
|
||||
const toolCallUpdate: ToolCallUpdate = {
|
||||
type: 'tool_call',
|
||||
toolCallId: 'test-1',
|
||||
kind: 'read',
|
||||
title: 'Read file',
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
||||
});
|
||||
|
||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0].timestamp).toBeDefined();
|
||||
expect(typeof toolCalls[0].timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('should preserve timestamp when updating tool call', () => {
|
||||
const { result } = renderHook(() => useToolCalls());
|
||||
|
||||
const timestamp = Date.now() - 1000; // 1 second ago
|
||||
|
||||
// Create tool call with specific timestamp
|
||||
const toolCallUpdate: ToolCallUpdate = {
|
||||
type: 'tool_call',
|
||||
toolCallId: 'test-1',
|
||||
kind: 'read',
|
||||
title: 'Read file',
|
||||
status: 'pending',
|
||||
timestamp,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
||||
});
|
||||
|
||||
// Update tool call without timestamp
|
||||
const toolCallUpdate2: ToolCallUpdate = {
|
||||
type: 'tool_call_update',
|
||||
toolCallId: 'test-1',
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleToolCallUpdate(toolCallUpdate2);
|
||||
});
|
||||
|
||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0].timestamp).toBe(timestamp);
|
||||
});
|
||||
|
||||
it('should use current time as default timestamp', () => {
|
||||
const { result } = renderHook(() => useToolCalls());
|
||||
|
||||
const before = Date.now();
|
||||
|
||||
const toolCallUpdate: ToolCallUpdate = {
|
||||
type: 'tool_call',
|
||||
toolCallId: 'test-1',
|
||||
kind: 'read',
|
||||
title: 'Read file',
|
||||
status: 'pending',
|
||||
// No timestamp provided
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleToolCallUpdate(toolCallUpdate);
|
||||
});
|
||||
|
||||
const after = Date.now();
|
||||
|
||||
const toolCalls = Array.from(result.current.toolCalls.values());
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0].timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(toolCalls[0].timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
@@ -108,11 +108,11 @@ export const useToolCalls = () => {
|
||||
newText: item.newText,
|
||||
}));
|
||||
|
||||
// 合并策略:对于 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user