mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
refactor(vscode): 重构消息排序和展示逻辑
- 移除旧的消息排序改进总结文档 - 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序 - 优化工具调用处理流程,添加时间戳支持 - 改进会话保存机制,直接使用SessionManager保存检查点 - 重构部分组件以提高可维护性
This commit is contained in:
@@ -62,6 +62,8 @@ export const App: React.FC = () => {
|
||||
} | null>(null);
|
||||
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
// Scroll container for message list; used to keep the view anchored to the latest content
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputFieldRef = useRef<HTMLDivElement>(null);
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
const [editMode, setEditMode] = useState<EditMode>('ask');
|
||||
@@ -173,6 +175,51 @@ export const App: React.FC = () => {
|
||||
setInputText,
|
||||
});
|
||||
|
||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||
// but don't interrupt the user if they scrolled up.
|
||||
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
const endEl = messagesEndRef.current;
|
||||
if (!container || !endEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom = () => {
|
||||
const threshold = 64; // px tolerance
|
||||
return (
|
||||
container.scrollTop + container.clientHeight >=
|
||||
container.scrollHeight - threshold
|
||||
);
|
||||
};
|
||||
|
||||
// Detect whether new items were appended (vs. streaming chunk updates)
|
||||
const prev = prevCountsRef.current;
|
||||
const newMsg = messageHandling.messages.length > prev.msgLen;
|
||||
const newInProg = inProgressToolCalls.length > prev.inProgLen;
|
||||
const newDone = completedToolCalls.length > prev.doneLen;
|
||||
prevCountsRef.current = {
|
||||
msgLen: messageHandling.messages.length,
|
||||
inProgLen: inProgressToolCalls.length,
|
||||
doneLen: completedToolCalls.length,
|
||||
};
|
||||
|
||||
// If user is near bottom, or if we just appended a new item, scroll to bottom
|
||||
if (nearBottom() || newMsg || newInProg || newDone) {
|
||||
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
|
||||
endEl.scrollIntoView({
|
||||
behavior: smooth ? 'smooth' : 'auto',
|
||||
block: 'end',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
messageHandling.messages,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
messageHandling.isWaitingForResponse,
|
||||
messageHandling.loadingMessage,
|
||||
]);
|
||||
|
||||
// Handle permission response
|
||||
const handlePermissionResponse = useCallback(
|
||||
(optionId: string) => {
|
||||
@@ -380,6 +427,7 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>.message-item]:px-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||
>
|
||||
@@ -387,23 +435,23 @@ export const App: React.FC = () => {
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* 创建统一的消息数组,包含所有类型的消息和工具调用 */}
|
||||
{/* Create unified message array containing all types of messages and tool calls */}
|
||||
{(() => {
|
||||
// 普通消息
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// 进行中的工具调用
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// 完成的工具调用
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
@@ -412,7 +460,7 @@ export const App: React.FC = () => {
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// 合并并按时间戳排序,确保消息与工具调用穿插显示
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
const allMessages = [
|
||||
...regularMessages,
|
||||
...inProgressTools,
|
||||
@@ -492,7 +540,7 @@ export const App: React.FC = () => {
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 已改为在 useWebViewMessages 中将每次 plan 推送为历史 toolcall,避免重复展示最新块 */}
|
||||
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}
|
||||
|
||||
{messageHandling.isWaitingForResponse &&
|
||||
messageHandling.loadingMessage && (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AuthStateManager } from '../auth/authStateManager.js';
|
||||
import { PanelManager } from '../webview/PanelManager.js';
|
||||
import { MessageHandler } from '../webview/MessageHandler.js';
|
||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
import { CliInstaller } from '../cli/CliInstaller.js';
|
||||
import { CliInstaller } from '../cli/1cliInstaller.js';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
|
||||
@@ -22,7 +22,7 @@ interface PlanDisplayProps {
|
||||
* PlanDisplay component - displays AI's task plan/todo list
|
||||
*/
|
||||
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||
// 计算整体状态用于左侧圆点颜色
|
||||
// Calculate overall status for left dot color
|
||||
const allCompleted =
|
||||
entries.length > 0 && entries.every((e) => e.status === 'completed');
|
||||
const anyInProgress = entries.some((e) => e.status === 'in_progress');
|
||||
@@ -36,14 +36,14 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||
<div
|
||||
className={[
|
||||
'plan-display',
|
||||
// 容器:类似示例中的 .A/.e
|
||||
// Container: Similar to example .A/.e
|
||||
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
|
||||
// 左侧状态圆点,类似示例 .e:before
|
||||
// Left status dot, similar to example .e:before
|
||||
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
|
||||
statusDotClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{/* 标题区域,类似示例中的 summary/_e/or */}
|
||||
{/* Title area, similar to example summary/_e/or */}
|
||||
<div className="plan-header w-full">
|
||||
<div className="relative">
|
||||
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
|
||||
@@ -56,7 +56,7 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表区域,类似示例中的 .qr/.Fr/.Hr */}
|
||||
{/* List area, similar to example .qr/.Fr/.Hr */}
|
||||
<div className="qr grid-cols-1 flex flex-col py-2">
|
||||
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||
{entries.map((entry, index) => {
|
||||
@@ -70,7 +70,7 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||
isDone ? 'fo opacity-70' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* 展示用复选框(复用组件) */}
|
||||
{/* Display checkbox (reusable component) */}
|
||||
<label className="flex items-start gap-2">
|
||||
<CheckboxDisplay
|
||||
checked={isDone}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* Save Session Dialog Styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* Session Manager Styles */
|
||||
.session-manager {
|
||||
display: flex;
|
||||
|
||||
@@ -18,18 +18,18 @@ export type TimelineItemType =
|
||||
export interface TimelineItemProps {
|
||||
type: TimelineItemType;
|
||||
children: React.ReactNode;
|
||||
/** 是否可折叠(主要用于工具输出) */
|
||||
/** Whether collapsible (mainly for tool output) */
|
||||
collapsible?: boolean;
|
||||
/** 默认是否展开 */
|
||||
/** Default expanded */
|
||||
defaultExpanded?: boolean;
|
||||
/** 自定义标题(用于折叠时显示) */
|
||||
/** Custom title (used for display when collapsed) */
|
||||
title?: string;
|
||||
/** 是否是最后一个项目 */
|
||||
/** Whether it is the last item */
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline 项目组件 - 统一展示消息和工具调用
|
||||
* Timeline item component - Unified display of messages and tool calls
|
||||
*/
|
||||
export const TimelineItem: React.FC<TimelineItemProps> = ({
|
||||
type,
|
||||
@@ -44,15 +44,15 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
||||
const getDotColor = (): string => {
|
||||
switch (type) {
|
||||
case 'user-message':
|
||||
return 'blue'; // 用户消息 - 蓝色
|
||||
return 'blue'; // User message - Blue
|
||||
case 'assistant-message':
|
||||
return 'gray'; // LLM 输出 - 灰色
|
||||
return 'gray'; // LLM output - Gray
|
||||
case 'tool-call':
|
||||
return 'green'; // 工具调用 - 绿色
|
||||
return 'green'; // Tool call - Green
|
||||
case 'tool-output':
|
||||
return 'gray'; // 工具输出 - 灰色
|
||||
return 'gray'; // Tool output - Gray
|
||||
case 'thinking':
|
||||
return 'purple'; // 思考 - 紫色
|
||||
return 'purple'; // Thinking - Purple
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
@@ -80,20 +80,20 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
||||
|
||||
return (
|
||||
<div className={`timeline-item ${type} ${isLast ? 'last' : ''}`}>
|
||||
{/* 时间线连接线 - 暂时禁用 */}
|
||||
{/* Timeline connecting line - Temporarily disabled */}
|
||||
{/* {!isLast && <div className="timeline-line" />} */}
|
||||
|
||||
{/* 状态圆点 */}
|
||||
{/* Status dot */}
|
||||
<div className={`timeline-dot ${dotColor}`}>
|
||||
<span className="dot-inner" />
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{/* Content area */}
|
||||
<div className="timeline-content">
|
||||
{/* 标签(可选) */}
|
||||
{/* Label (optional) */}
|
||||
{itemLabel && <div className="timeline-label">{itemLabel}</div>}
|
||||
|
||||
{/* 可折叠内容 */}
|
||||
{/* Collapsible content */}
|
||||
{collapsible ? (
|
||||
<>
|
||||
<button
|
||||
@@ -119,7 +119,7 @@ export const TimelineItem: React.FC<TimelineItemProps> = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Timeline 容器组件
|
||||
* Timeline container component
|
||||
*/
|
||||
export const Timeline: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
|
||||
@@ -48,31 +48,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
{/* Spacer */}
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Save Session Button */}
|
||||
{/* <button
|
||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
style={{
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
onClick={onSaveSession}
|
||||
title="Save Conversation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button> */}
|
||||
|
||||
{/* New Session Button */}
|
||||
<button
|
||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
|
||||
@@ -32,7 +32,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
onFileClick,
|
||||
status = 'default',
|
||||
}) => {
|
||||
// 空内容直接不渲染,避免只显示 ::before 的圆点导致观感不佳
|
||||
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
|
||||
if (!content || content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Use explicit Vitest imports instead of relying on globals.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ToolCallData } from '../toolcalls/shared/types.js';
|
||||
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ interface SessionSelectorProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话选择器组件
|
||||
* 显示会话列表并支持搜索和选择
|
||||
* Session selector component
|
||||
* Display session list and support search and selection
|
||||
*/
|
||||
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
||||
visible,
|
||||
|
||||
@@ -36,7 +36,7 @@ const mapToolStatusToBullet = (
|
||||
}
|
||||
};
|
||||
|
||||
// 从文本中尽可能解析带有 - [ ] / - [x] 的 todo 列表
|
||||
// Parse todo list with - [ ] / - [x] from text as much as possible
|
||||
const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
||||
const text = textOutputs.join('\n');
|
||||
const lines = text.split(/\r?\n/);
|
||||
@@ -60,7 +60,7 @@ const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没匹配到,退化为将非空行当作 pending 条目
|
||||
// If no match is found, fall back to treating non-empty lines as pending items
|
||||
if (entries.length === 0) {
|
||||
for (const line of lines) {
|
||||
const title = line.trim();
|
||||
@@ -83,7 +83,7 @@ export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
|
||||
const { content, status } = toolCall;
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// 错误优先展示
|
||||
// Error-first display
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="Update Todos" status="error">
|
||||
|
||||
@@ -29,7 +29,7 @@ interface DiffDisplayProps {
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
onOpenDiff?: () => void;
|
||||
/** 是否显示统计信息 */
|
||||
/** Whether to display statistics */
|
||||
showStats?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
||||
onOpenDiff,
|
||||
showStats = true,
|
||||
}) => {
|
||||
// 统计信息(仅在文本变化时重新计算)
|
||||
// Statistics (recalculate only when text changes)
|
||||
const stats = useMemo(
|
||||
() => calculateDiffStats(oldText, newText),
|
||||
[oldText, newText],
|
||||
);
|
||||
|
||||
// 仅生成变更行(增加/删除),不渲染上下文
|
||||
// Only generate changed lines (additions/deletions), do not render context
|
||||
const ops: DiffOp[] = useMemo(
|
||||
() => computeLineDiff(oldText, newText),
|
||||
[oldText, newText],
|
||||
@@ -106,7 +106,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 只绘制差异行的预览区域 */}
|
||||
{/* Only draw preview area for diff lines */}
|
||||
<pre className="diff-preview code-block" aria-label="Diff preview">
|
||||
<div className="code-content">
|
||||
{previewOps.length === 0 && (
|
||||
@@ -142,7 +142,7 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
||||
</div>
|
||||
</pre>
|
||||
|
||||
{/* 在预览下方提供显式打开按钮(可选) */}
|
||||
{/* Provide explicit open button below preview (optional) */}
|
||||
{onOpenDiff && (
|
||||
<div className="diff-compact-actions">
|
||||
<button
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface ToolCallData {
|
||||
rawInput?: string | object;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
timestamp?: number; // 添加时间戳字段用于消息排序
|
||||
timestamp?: number; // Add a timestamp field for message sorting
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Button component using Tailwind CSS
|
||||
* This is an example of how to create new components using Tailwind
|
||||
* while maintaining compatibility with existing CSS-based components
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
/**
|
||||
* Button variant style
|
||||
*/
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'icon';
|
||||
|
||||
/**
|
||||
* Button size
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Button contents
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional click handler
|
||||
*/
|
||||
onClick?: () => void;
|
||||
|
||||
/**
|
||||
* Disable button
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* Additional class names
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction
|
||||
*/
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
// Base classes that apply to all buttons
|
||||
const baseClasses = "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
|
||||
|
||||
// Variant-specific classes
|
||||
const variantClasses = {
|
||||
primary: "bg-qwen-orange text-qwen-ivory hover:bg-qwen-clay-orange shadow-sm",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700",
|
||||
ghost: "hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
icon: "hover:bg-gray-100 dark:hover:bg-gray-800 p-1"
|
||||
};
|
||||
|
||||
// Size-specific classes
|
||||
const sizeClasses = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4 py-2 text-sm",
|
||||
lg: "h-12 px-6 text-base"
|
||||
};
|
||||
|
||||
// Combine all classes
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Card component using Tailwind CSS
|
||||
* This demonstrates how to create new components with Tailwind
|
||||
* while maintaining compatibility with existing components
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
/**
|
||||
* Card contents
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Additional class names
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
/**
|
||||
* Card header contents
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Additional class names
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardContentProps {
|
||||
/**
|
||||
* Card content contents
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Additional class names
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
/**
|
||||
* Card footer contents
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Additional class names
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card container component
|
||||
*/
|
||||
const Card: React.FC<CardProps> & {
|
||||
Header: React.FC<CardHeaderProps>;
|
||||
Content: React.FC<CardContentProps>;
|
||||
Footer: React.FC<CardFooterProps>;
|
||||
} = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`rounded-lg border bg-card text-card-foreground shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Card header component
|
||||
*/
|
||||
const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col space-y-1.5 p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Card content component
|
||||
*/
|
||||
const CardContent: React.FC<CardContentProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`p-6 pt-0 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Card footer component
|
||||
*/
|
||||
const CardFooter: React.FC<CardFooterProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex items-center p-6 pt-0 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Compose the Card component with its subcomponents
|
||||
Card.Header = CardHeader;
|
||||
Card.Content = CardContent;
|
||||
Card.Footer = CardFooter;
|
||||
|
||||
export { Card };
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
currentSessionTitle: string;
|
||||
onLoadSessions: () => void;
|
||||
onSaveSession: () => void;
|
||||
onNewSession: () => void;
|
||||
}
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
currentSessionTitle,
|
||||
onLoadSessions,
|
||||
onSaveSession: _onSaveSession,
|
||||
onNewSession,
|
||||
}) => (
|
||||
<div
|
||||
className="flex gap-1 select-none py-1.5 px-2.5"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--app-primary-border-color)',
|
||||
backgroundColor: 'var(--app-header-background)',
|
||||
}}
|
||||
>
|
||||
{/* Past Conversations Button */}
|
||||
<button
|
||||
className="flex-none py-1 px-2 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none font-medium transition-colors duration-200 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
style={{
|
||||
borderRadius: 'var(--corner-radius-small)',
|
||||
color: 'var(--app-primary-foreground)',
|
||||
fontSize: 'var(--vscode-chat-font-size, 13px)',
|
||||
}}
|
||||
onClick={onLoadSessions}
|
||||
title="Past conversations"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ fontSize: 'var(--vscode-chat-font-size, 13px)' }}>
|
||||
{currentSessionTitle}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Save Session Button */}
|
||||
{/* <button
|
||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
style={{
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
onClick={onSaveSession}
|
||||
title="Save Conversation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v11.5A2.25 2.25 0 0 0 4.25 18h11.5A2.25 2.25 0 0 0 18 15.75V8.25a.75.75 0 0 1 .217-.517l.083-.083a.75.75 0 0 1 1.061 0l2.239 2.239A.75.75 0 0 1 22 10.5v5.25a4.75 4.75 0 0 1-4.75 4.75H4.75A4.75 4.75 0 0 1 0 15.75V4.25A4.75 4.75 0 0 1 4.75 0h5a.75.75 0 0 1 0 1.5h-5ZM9.017 6.5a1.5 1.5 0 0 1 2.072.58l.43.862a1 1 0 0 0 .895.558h3.272a1.5 1.5 0 0 1 1.5 1.5v6.75a1.5 1.5 0 0 1-1.5 1.5h-7.5a1.5 1.5 0 0 1-1.5-1.5v-6.75a1.5 1.5 0 0 1 1.5-1.5h1.25a1 1 0 0 0 .895-.558l.43-.862a1.5 1.5 0 0 1 .511-.732ZM11.78 8.47a.75.75 0 0 0-1.06-1.06L8.75 9.379 7.78 8.41a.75.75 0 0 0-1.06 1.06l1.5 1.5a.75.75 0 0 0 1.06 0l2.5-2.5Z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button> */}
|
||||
|
||||
{/* New Session Button */}
|
||||
<button
|
||||
className="flex-none p-0 bg-transparent border border-transparent rounded cursor-pointer flex items-center justify-center outline-none w-6 h-6 hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
style={{
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
onClick={onNewSession}
|
||||
title="New Session"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -25,6 +25,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
'getQwenSessions',
|
||||
'saveSession',
|
||||
'resumeSession',
|
||||
// UI action: open a new chat tab (new WebviewPanel)
|
||||
'openNewChatTab',
|
||||
].includes(messageType);
|
||||
}
|
||||
|
||||
@@ -82,6 +84,24 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
await this.handleResumeSession((data?.sessionId as string) || '');
|
||||
break;
|
||||
|
||||
case 'openNewChatTab':
|
||||
// Open a brand new chat tab (WebviewPanel) via the extension command
|
||||
// This does not alter the current conversation in this tab; the new tab
|
||||
// will initialize its own state and (optionally) create a new session.
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwenCode.openNewChatTab');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[SessionMessageHandler] Failed to open new chat tab:',
|
||||
error,
|
||||
);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to open new chat tab: ${error}` },
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Unknown message type:',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
@@ -30,7 +30,7 @@ export interface ToolCallUpdate {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
timestamp?: number; // 添加时间戳字段用于消息排序
|
||||
timestamp?: number; // Add timestamp field for message ordering
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user