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

@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Claude Code-like dropdown anchored to input container */
.hi {
display: flex;
flex-direction: column;
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: var(--app-menu-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-large);
overflow: hidden;
animation: So .15s ease-out;
max-height: 50vh;
z-index: 1000;
}
/* Optional top spacer to create visual separation from input */
.hi > .spacer-4px { height: 4px; }
.xi {
max-height: 300px;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: var(--app-list-padding);
gap: var(--app-list-gap);
padding-bottom: 8px;
}
.fi { /* divider */
height: 1px;
background: var(--app-input-border);
margin: 4px 0;
}
.vi { /* section label */
padding: 4px 12px;
color: var(--app-primary-foreground);
opacity: .5;
font-size: .9em;
}
.wi { /* item */
padding: var(--app-list-item-padding);
margin: 0 4px;
cursor: pointer;
border-radius: var(--app-list-border-radius);
}
.ki { /* item content */
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.Ii { /* leading icon */
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--vscode-symbolIcon-fileForeground, #cccccc);
}
.Lo { /* primary text */
color: var(--app-primary-foreground);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Mo { /* secondary text (path/description) */
color: var(--app-secondary-foreground);
opacity: .7;
font-size: .9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.jo { /* active/selected */
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
}
.jo .Lo { color: var(--app-list-active-foreground); }
.yi { /* trailing icon placeholder */
width: 16px;
height: 16px;
opacity: .5;
margin-left: auto;
}
@keyframes So {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Container around the input to anchor the dropdown */
.Bo {
position: relative;
display: flex;
}

View File

@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import './ClaudeCompletionMenu.css';
import type { CompletionItem } from './CompletionMenu.js';
interface ClaudeCompletionMenuProps {
items: CompletionItem[];
onSelect: (item: CompletionItem) => void;
onClose: () => void;
title?: string;
selectedIndex?: number;
}
/**
* Claude Code-like anchored dropdown rendered above the input field.
* Keyboard: Up/Down to move, Enter to select, Esc to close.
*/
export const ClaudeCompletionMenu: React.FC<ClaudeCompletionMenuProps> = ({
items,
onSelect,
onClose,
title,
selectedIndex = 0,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(selectedIndex);
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setSelected((prev) => Math.min(prev + 1, items.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
setSelected((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
event.preventDefault();
if (items[selected]) {
onSelect(items[selected]);
}
break;
case 'Escape':
event.preventDefault();
onClose();
break;
default:
break;
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [items, selected, onSelect, onClose]);
useEffect(() => {
const selectedEl = containerRef.current?.querySelector(
`[data-index="${selected}"]`,
);
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}, [selected]);
if (!items.length) {
return null;
}
return (
<div ref={containerRef} className="hi" role="menu">
<div className="spacer-4px" />
<div className="xi">
{title && <div className="vi">{title}</div>}
{items.map((item, index) => {
const selectedCls = index === selected ? 'jo' : '';
return (
<div
key={item.id}
data-index={index}
className={`wi ${selectedCls}`}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
role="menuitem"
>
<div className="ki">
{item.icon && <span className="Ii">{item.icon}</span>}
<span className="Lo">{item.label}</span>
{item.description && (
<span className="Mo" title={item.description}>
{item.description}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -13,8 +13,11 @@ export interface CompletionItem {
label: string;
description?: string;
icon?: React.ReactNode;
type: 'file' | 'symbol' | 'command' | 'variable';
value?: unknown;
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
// Value inserted into the input when selected (e.g., filename or command)
value?: string;
// Optional full path for files (used to build @filename -> full path mapping)
path?: string;
}
interface CompletionMenuProps {

View File

@@ -6,12 +6,14 @@
* In-progress tool call component - displays active tool calls with Claude Code style
*/
import type React from 'react';
import React from 'react';
import type { ToolCallData } from './toolcalls/shared/types.js';
import { FileLink } from './shared/FileLink.js';
import { useVSCode } from '../hooks/useVSCode.js';
interface InProgressToolCallProps {
toolCall: ToolCallData;
onFileClick?: (path: string, line?: number | null) => void;
}
/**
@@ -40,65 +42,158 @@ const formatKind = (kind: string): string => {
};
/**
* Get status display text
* Get file name from path
*/
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
failed: 'Failed',
};
return statusMap[status] || status;
};
const getFileName = (path: string): string => path.split('/').pop() || path;
/**
* Component to display in-progress tool calls with Claude Code styling
* Shows kind, status, and file locations
* Shows kind, file name, and file locations
*/
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
toolCall,
onFileClick: _onFileClick,
}) => {
const { kind, status, title, locations } = toolCall;
const { kind, title, locations, content } = toolCall;
const vscode = useVSCode();
// Format the kind label
const kindLabel = formatKind(kind);
// Get status text
const statusText = getStatusText(status || 'in_progress');
// Map tool kind to a Tailwind text color class (Claude-like palette)
const kindColorClass = React.useMemo(() => {
const k = kind.toLowerCase();
if (k === 'read') {
return 'text-[#4ec9b0]';
}
if (k === 'write' || k === 'edit') {
return 'text-[#e5c07b]';
}
if (k === 'execute' || k === 'bash' || k === 'command') {
return 'text-[#c678dd]';
}
if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') {
return 'text-[#61afef]';
}
if (k === 'think' || k === 'thinking') {
return 'text-[#98c379]';
}
return 'text-[var(--app-primary-foreground)]';
}, [kind]);
// Safely prepare a display value for title. Titles may sometimes arrive as
// non-string objects; ensure we render a string in that case.
const titleText = typeof title === 'string' ? title : undefined;
const titleDisplay: React.ReactNode =
typeof title === 'string' ? title : title ? JSON.stringify(title) : null;
// Get file name from locations or title
let fileName: string | null = null;
let filePath: string | null = null;
let fileLine: number | null = null;
if (locations && locations.length > 0) {
fileName = getFileName(locations[0].path);
filePath = locations[0].path;
fileLine = locations[0].line || null;
} else if (typeof title === 'string') {
fileName = title;
}
// Extract content text from content array
let contentText: string | null = null;
// Extract first diff (if present)
let diffData: {
path?: string;
oldText?: string | null;
newText?: string;
} | null = null;
if (content && content.length > 0) {
// Look for text content
for (const item of content) {
if (item.type === 'content' && item.content?.text) {
contentText = item.content.text;
break;
}
}
// If no text content found, look for other content types
if (!contentText) {
for (const item of content) {
if (item.type === 'content' && item.content) {
contentText = JSON.stringify(item.content, null, 2);
break;
}
}
}
// Look for diff content
for (const item of content) {
if (
item.type === 'diff' &&
(item.oldText !== undefined || item.newText !== undefined)
) {
diffData = {
path: item.path,
oldText: item.oldText ?? null,
newText: item.newText,
};
break;
}
}
}
// Handle open diff
const handleOpenDiff = () => {
if (!diffData) {
return;
}
const path = diffData.path || filePath || '';
vscode.postMessage({
type: 'openDiff',
data: {
path,
oldText: diffData.oldText || '',
newText: diffData.newText || '',
},
});
};
return (
<div className="in-progress-tool-call">
<div className="in-progress-tool-call-header">
<span className="in-progress-tool-call-kind">{kindLabel}</span>
<div className="relative py-2 toolcall-container">
<div className="flex items-center gap-2 mb-1 relative">
{/* Pulsing bullet dot (Claude-style), vertically centered with header row */}
<span
className={`in-progress-tool-call-status ${status || 'in_progress'}`}
aria-hidden
className="absolute -left-[20px] top-1/2 -translate-y-1/2 text-[10px] leading-none text-[#e1c08d] animate-[pulse_1.5s_ease-in-out_infinite]"
>
{statusText}
</span>
<span className={`text-xs font-medium ${kindColorClass}`}>
{kindLabel}
</span>
{filePath && (
<FileLink
path={filePath}
line={fileLine ?? undefined}
showFullPath={false}
className="text-xs"
/>
)}
{!filePath && fileName && (
<span className="text-xs text-[var(--app-secondary-foreground)] font-mono">
{fileName}
</span>
)}
{diffData && (
<button
type="button"
onClick={handleOpenDiff}
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
>
Open Diff
</button>
)}
</div>
{titleDisplay && (titleText ? titleText !== kindLabel : true) && (
<div className="in-progress-tool-call-title">{titleDisplay}</div>
)}
{locations && locations.length > 0 && (
<div className="in-progress-tool-call-locations">
{locations.map((loc, idx) => (
<FileLink
key={idx}
path={loc.path}
line={loc.line}
showFullPath={false}
/>
))}
{contentText && (
<div className="text-xs text-[var(--app-secondary-foreground)] font-mono mt-1 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto p-1 bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded">
{contentText}
</div>
)}
</div>

View File

@@ -15,6 +15,8 @@ import {
LinkIcon,
ArrowUpIcon,
} from './icons/index.js';
import { ClaudeCompletionMenu } from './ClaudeCompletionMenu.js';
import type { CompletionItem } from './CompletionMenu.js';
type EditMode = 'ask' | 'auto' | 'plan';
@@ -40,6 +42,9 @@ interface InputFormProps {
onShowCommandMenu: () => void;
onAttachContext: () => void;
completionIsOpen: boolean;
completionItems?: CompletionItem[];
onCompletionSelect?: (item: CompletionItem) => void;
onCompletionClose?: () => void;
}
// Get edit mode display info
@@ -92,6 +97,10 @@ export const InputForm: React.FC<InputFormProps> = ({
onShowCommandMenu,
onAttachContext,
completionIsOpen,
// Claude-style completion dropdown (optional)
completionItems,
onCompletionSelect,
onCompletionClose,
}) => {
const editModeInfo = getEditModeInfo(editMode);
@@ -133,12 +142,27 @@ export const InputForm: React.FC<InputFormProps> = ({
{/* Banner area */}
<div className="input-banner" />
{/* Input wrapper */}
<div className="relative flex z-[1]">
{/* Input wrapper (Claude-style anchor container) */}
<div className="relative flex z-[1] Bo">
{/* Claude-style anchored dropdown */}
{completionIsOpen &&
completionItems &&
completionItems.length > 0 &&
onCompletionSelect &&
onCompletionClose && (
// Render dropdown above the input, matching Claude Code
<ClaudeCompletionMenu
items={completionItems}
onSelect={onCompletionSelect}
onClose={onCompletionClose}
title={undefined}
/>
)}
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="flex-1 self-stretch p-2.5 px-3.5 outline-none font-inherit leading-relaxed overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-none rounded-none overflow-x-hidden break-words whitespace-pre-wrap empty:before:content-[attr(data-placeholder)] empty:before:absolute empty:before:pointer-events-none disabled:text-gray-400 disabled:cursor-not-allowed"
className="c flex-1 self-stretch p-2.5 px-3.5 outline-none font-inherit leading-relaxed overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-none rounded-none overflow-x-hidden break-words whitespace-pre-wrap empty:before:content-[attr(data-placeholder)] empty:before:absolute empty:before:pointer-events-none disabled:text-gray-400 disabled:cursor-not-allowed"
style={{
color: 'var(--app-input-foreground)',
fontSize: 'var(--vscode-chat-font-size, 13px)',

View File

@@ -134,7 +134,7 @@ export const MessageContent: React.FC<MessageContentProps> = ({
parts.push(
<code
key={`inline-${matchIndex}`}
className="rounded px-1.5 py-0.5 whitespace-nowrap text-[0.9em]"
className="rounded px-1.5 py-0.5 whitespace-nowrap text-[0.9em] inline-block max-w-full overflow-hidden text-ellipsis align-baseline"
style={{
backgroundColor: 'var(--app-code-background, rgba(0, 0, 0, 0.05))',
border: '1px solid var(--app-primary-border-color)',

View File

@@ -68,7 +68,9 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (!isOpen) {
return;
}
// Number keys 1-9 for quick select
const numMatch = e.key.match(/^[1-9]$/);
@@ -123,7 +125,9 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
}
}, [isOpen]);
if (!isOpen) return null;
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
@@ -162,18 +166,11 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
return (
<button
key={option.optionId}
className={`flex items-center gap-2 px-3 py-2 text-left rounded-small border transition-colors duration-150 ${
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] ${
isFocused
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
: 'hover:bg-[var(--app-list-hover-background)]'
}`}
style={{
color: isFocused
? 'var(--app-list-active-foreground)'
: 'var(--app-primary-foreground)',
borderColor:
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
}}
onClick={() => onResponse(option.optionId)}
onMouseEnter={() => setFocusedIndex(index)}
>
@@ -197,39 +194,60 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
);
})}
{/* Custom message input */}
<input
ref={customInputRef as React.RefObject<HTMLInputElement>}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className={`w-full px-3 py-2 text-sm rounded-small border transition-colors duration-150 ${
focusedIndex === options.length
? 'bg-[var(--app-list-hover-background)]'
: 'bg-transparent'
}`}
style={{
color: 'var(--app-input-foreground)',
outline: 'none',
borderColor:
'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)',
}}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={() => setFocusedIndex(options.length)}
onMouseEnter={() => setFocusedIndex(options.length)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
const rejectOption = options.find((o) =>
o.kind.includes('reject'),
);
if (rejectOption) {
onResponse(rejectOption.optionId);
}
}
}}
/>
{/* Custom message input (styled consistently with option items) */}
{(() => {
const isFocused = focusedIndex === options.length;
return (
<div
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 cursor-text text-[var(--app-primary-foreground)] ${
isFocused
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]'
: 'hover:bg-[var(--app-list-hover-background)]'
}`}
onMouseEnter={() => setFocusedIndex(options.length)}
onClick={() => customInputRef.current?.focus()}
>
{/* Number badge (N+1) */}
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
isFocused
? 'bg-white/20 text-inherit'
: 'bg-[var(--app-list-hover-background)]'
}`}
>
{options.length + 1}
</span>
{/* Input field */}
<input
ref={customInputRef as React.RefObject<HTMLInputElement>}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
style={{ color: 'var(--app-input-foreground)' }}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={() => setFocusedIndex(options.length)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
customMessage.trim()
) {
e.preventDefault();
const rejectOption = options.find((o) =>
o.kind.includes('reject'),
);
if (rejectOption) {
onResponse(rejectOption.optionId);
}
}
}}
/>
</div>
);
})()}
</div>
</div>

View File

@@ -5,115 +5,83 @@
*/
/**
* PlanDisplay.css - Styles for the task plan component
* Clean checklist-style design matching Claude Code CLI
* PlanDisplay.css -> Tailwind 化
* 说明:尽量用 @apply把原有类名保留便于调试
* 仅在必须的地方保留少量原生 CSS如关键帧
*/
/* 容器 */
.plan-display {
background: transparent;
border: none;
padding: 8px 16px;
margin: 8px 0;
@apply bg-transparent border-0 py-2 px-4 my-2;
}
/* 标题区 */
.plan-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
@apply flex items-center gap-1.5 mb-2;
}
.plan-progress-icons {
display: flex;
align-items: center;
gap: 2px;
@apply flex items-center gap-[2px];
}
.plan-progress-icon {
flex-shrink: 0;
color: var(--app-secondary-foreground);
opacity: 0.6;
@apply shrink-0 text-[var(--app-secondary-foreground)] opacity-60;
}
.plan-title {
font-size: 12px;
font-weight: 400;
color: var(--app-secondary-foreground);
opacity: 0.8;
@apply text-xs font-normal text-[var(--app-secondary-foreground)] opacity-80;
}
/* 列表 */
.plan-entries {
display: flex;
flex-direction: column;
gap: 1px;
@apply flex flex-col gap-px;
}
.plan-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
min-height: 20px;
@apply flex items-center gap-2 py-[3px] min-h-[20px];
}
/* Icon container */
/* 图标容器(保留类名以兼容旧 DOM */
.plan-entry-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
@apply shrink-0 flex items-center justify-center w-[14px] h-[14px];
}
.plan-icon {
display: block;
width: 14px;
height: 14px;
@apply block w-[14px] h-[14px];
}
/* 不同状态的图标颜色 */
/* 不同状态颜色(保留类名) */
.plan-icon.pending {
color: var(--app-secondary-foreground);
opacity: 0.35;
@apply text-[var(--app-secondary-foreground)] opacity-30;
}
.plan-icon.in-progress {
color: var(--app-secondary-foreground);
opacity: 0.7;
@apply text-[var(--app-secondary-foreground)] opacity-70;
}
.plan-icon.completed {
color: #4caf50; /* 绿色勾号 */
opacity: 0.8;
@apply text-[#4caf50] opacity-80; /* 绿色勾号 */
}
/* Content */
/* 内容 */
.plan-entry-content {
flex: 1;
display: flex;
align-items: center;
@apply flex-1 flex items-center;
}
.plan-entry-text {
flex: 1;
font-size: 12px;
line-height: 1.5;
color: var(--app-primary-foreground);
opacity: 0.85;
@apply flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)] opacity-80;
}
/* Status-specific styles */
/* 状态化文本(保留选择器,兼容旧结构) */
.plan-entry.completed .plan-entry-text {
opacity: 0.5;
text-decoration: line-through;
@apply opacity-50 line-through;
}
.plan-entry.in_progress .plan-entry-text {
font-weight: 400;
opacity: 0.9;
@apply font-normal opacity-90;
}
/* 保留 fadeIn 动画,供 App.tsx 使用 */
@keyframes fadeIn {
from {
opacity: 0;

View File

@@ -5,12 +5,8 @@
*/
import type React from 'react';
import {
PlanCompletedIcon,
PlanInProgressIcon,
PlanPendingIcon,
} from './icons/index.js';
import './PlanDisplay.css';
import { CheckboxDisplay } from './ui/CheckboxDisplay.js';
export interface PlanEntry {
content: string;
@@ -26,42 +22,76 @@ interface PlanDisplayProps {
* PlanDisplay component - displays AI's task plan/todo list
*/
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
// 计算完成进度
const completedCount = entries.filter((e) => e.status === 'completed').length;
const totalCount = entries.length;
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <PlanCompletedIcon className="plan-icon completed" />;
case 'in_progress':
return <PlanInProgressIcon className="plan-icon in-progress" />;
default:
// pending
return <PlanPendingIcon className="plan-icon pending" />;
}
};
// 计算整体状态用于左侧圆点颜色
const allCompleted =
entries.length > 0 && entries.every((e) => e.status === 'completed');
const anyInProgress = entries.some((e) => e.status === 'in_progress');
const statusDotClass = allCompleted
? 'before:text-[#74c991]'
: anyInProgress
? 'before:text-[#e1c08d]'
: 'before:text-[var(--app-secondary-foreground)]';
return (
<div className="plan-display">
<div className="plan-header">
<div className="plan-progress-icons">
<PlanPendingIcon className="plan-progress-icon" />
<PlanCompletedIcon className="plan-progress-icon" />
</div>
<span className="plan-title">
{completedCount} of {totalCount} Done
</span>
</div>
<div className="plan-entries">
{entries.map((entry, index) => (
<div key={index} className={`plan-entry ${entry.status}`}>
<div className="plan-entry-icon">{getStatusIcon(entry.status)}</div>
<div className="plan-entry-content">
<span className="plan-entry-text">{entry.content}</span>
</div>
<div
className={[
'plan-display',
// 容器:类似示例中的 .A/.e
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
// 左侧状态圆点,类似示例 .e:before
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
statusDotClass,
].join(' ')}
>
{/* 标题区域,类似示例中的 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">
<span>
<div>
<span className="or font-bold mr-1">Update Todos</span>
</div>
</span>
</div>
))}
</div>
</div>
{/* 列表区域,类似示例中的 .qr/.Fr/.Hr */}
<div className="qr grid-cols-1 flex flex-col py-2">
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
{entries.map((entry, index) => {
const isDone = entry.status === 'completed';
const isIndeterminate = entry.status === 'in_progress';
return (
<li
key={index}
className={[
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
isDone ? 'fo opacity-70' : '',
].join(' ')}
>
{/* 展示用复选框(复用组件) */}
<label className="flex items-start gap-2">
<CheckboxDisplay
checked={isDone}
indeterminate={isIndeterminate}
/>
</label>
<div
className={[
'vo plan-entry-text flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
isDone
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
: 'opacity-85',
].join(' ')}
>
{entry.content}
</div>
</li>
);
})}
</ul>
</div>
</div>
);

View File

@@ -82,3 +82,26 @@ export const SaveDocumentIcon: React.FC<IconProps> = ({
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
</svg>
);
/**
* Folder icon (16x16)
* Useful for directory entries in completion lists
*/
export const FolderIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.086a1.5 1.5 0 0 1 1.06.44L8.5 3H13A1.5 1.5 0 0 1 14.5 4.5v7A1.5 1.5 0 0 1 13 13H3A1.5 1.5 0 0 1 1.5 11.5v-8Z" />
</svg>
);

View File

@@ -10,7 +10,12 @@
export type { IconProps } from './types.js';
// File icons
export { FileIcon, FileListIcon, SaveDocumentIcon } from './FileIcons.js';
export {
FileIcon,
FileListIcon,
SaveDocumentIcon,
FolderIcon,
} from './FileIcons.js';
// Navigation icons
export {

View File

@@ -32,6 +32,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
onFileClick,
status = 'default',
}) => {
// 空内容直接不渲染,避免只显示 ::before 的圆点导致观感不佳
if (!content || content.trim().length === 0) {
return null;
}
// Map status to CSS class (only for ::before pseudo-element)
const getStatusClass = () => {
switch (status) {

View File

@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolCallData } from '../toolcalls/shared/types.js';
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
describe('Message Ordering', () => {
it('should correctly identify tool calls with output', () => {
// Test failed tool call (should show)
const failedToolCall: ToolCallData = {
toolCallId: 'test-1',
kind: 'read',
title: 'Read file',
status: 'failed',
timestamp: 1000,
};
expect(hasToolCallOutput(failedToolCall)).toBe(true);
// Test execute tool call with title (should show)
const executeToolCall: ToolCallData = {
toolCallId: 'test-2',
kind: 'execute',
title: 'ls -la',
status: 'completed',
timestamp: 2000,
};
expect(hasToolCallOutput(executeToolCall)).toBe(true);
// Test tool call with content (should show)
const contentToolCall: ToolCallData = {
toolCallId: 'test-3',
kind: 'read',
title: 'Read file',
status: 'completed',
content: [
{
type: 'content',
content: {
type: 'text',
text: 'File content',
},
},
],
timestamp: 3000,
};
expect(hasToolCallOutput(contentToolCall)).toBe(true);
// Test tool call with locations (should show)
const locationToolCall: ToolCallData = {
toolCallId: 'test-4',
kind: 'read',
title: 'Read file',
status: 'completed',
locations: [
{
path: '/path/to/file.txt',
},
],
timestamp: 4000,
};
expect(hasToolCallOutput(locationToolCall)).toBe(true);
// Test tool call with title (should show)
const titleToolCall: ToolCallData = {
toolCallId: 'test-5',
kind: 'generic',
title: 'Generic tool call',
status: 'completed',
timestamp: 5000,
};
expect(hasToolCallOutput(titleToolCall)).toBe(true);
// Test tool call without output (should not show)
const noOutputToolCall: ToolCallData = {
toolCallId: 'test-6',
kind: 'generic',
title: '',
status: 'completed',
timestamp: 6000,
};
expect(hasToolCallOutput(noOutputToolCall)).toBe(false);
});
});

View File

@@ -29,7 +29,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({
}) => {
// Generate display text for file context
const getFileContextDisplay = () => {
if (!fileContext) return null;
if (!fileContext) {
return null;
}
const { fileName, startLine, endLine } = fileContext;
if (startLine && endLine) {
return startLine === endLine

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Subtle shimmering highlight across the loading text */
@keyframes waitingMessageShimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.loading-text-shimmer {
/* Use the theme foreground as the base color, with a moving light band */
background-image: linear-gradient(
90deg,
var(--app-secondary-foreground) 0%,
var(--app-secondary-foreground) 40%,
rgba(255, 255, 255, 0.95) 50%,
var(--app-secondary-foreground) 60%,
var(--app-secondary-foreground) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* text color comes from the gradient */
animation: waitingMessageShimmer 1.6s linear infinite;
}

View File

@@ -5,27 +5,84 @@
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import './AssistantMessage.css';
import './WaitingMessage.css';
import { WITTY_LOADING_PHRASES } from '../../../constants/loadingMessages.js';
interface WaitingMessageProps {
loadingMessage: string;
}
// Rotate message every few seconds while waiting
const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
loadingMessage,
}) => (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85 animate-[fadeIn_0.2s_ease-in]">
<div className="bg-transparent border-0 py-2 flex items-center gap-2">
<span className="inline-flex items-center gap-1 mr-0">
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0s]"></span>
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.2s]"></span>
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full mr-0 opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.4s]"></span>
</span>
<span
className="opacity-70 italic"
style={{ color: 'var(--app-secondary-foreground)' }}
}) => {
// Build a phrase list that starts with the provided message (if any), then witty fallbacks
const phrases = useMemo(() => {
const set = new Set<string>();
const list: string[] = [];
if (loadingMessage && loadingMessage.trim()) {
list.push(loadingMessage);
set.add(loadingMessage);
}
for (const p of WITTY_LOADING_PHRASES) {
if (!set.has(p)) {
list.push(p);
}
}
return list;
}, [loadingMessage]);
const [index, setIndex] = useState(0);
// Reset to the first phrase whenever the incoming message changes
useEffect(() => {
setIndex(0);
}, [phrases]);
// Periodically rotate to a different phrase
useEffect(() => {
if (phrases.length <= 1) {
return;
}
const id = setInterval(() => {
setIndex((prev) => {
// pick a different random index to avoid immediate repeats
let next = Math.floor(Math.random() * phrases.length);
if (phrases.length > 1) {
let guard = 0;
while (next === prev && guard < 5) {
next = Math.floor(Math.random() * phrases.length);
guard++;
}
}
return next;
});
}, ROTATE_INTERVAL_MS);
return () => clearInterval(id);
}, [phrases]);
return (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85 animate-[fadeIn_0.2s_ease-in]">
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
<div
className="assistant-message-container assistant-message-loading"
style={{
width: '100%',
alignItems: 'flex-start',
paddingLeft: '30px', // reserve space for ::before bullet
position: 'relative',
paddingTop: '8px',
paddingBottom: '8px',
}}
>
{loadingMessage}
</span>
<span className="opacity-70 italic loading-text-shimmer">
{phrases[index]}
</span>
</div>
</div>
</div>
);
);
};

View File

@@ -9,3 +9,4 @@ export { AssistantMessage } from './AssistantMessage.js';
export { ThinkingMessage } from './ThinkingMessage.js';
export { StreamingMessage } from './StreamingMessage.js';
export { WaitingMessage } from './WaitingMessage.js';
export { PlanDisplay } from '../PlanDisplay.js';

View File

@@ -7,7 +7,6 @@
*/
import type React from 'react';
import { useState } from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { DiffDisplay } from './shared/DiffDisplay.js';
@@ -42,7 +41,6 @@ const getDiffSummary = (
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { content, locations, toolCallId } = toolCall;
const vscode = useVSCode();
const [expanded, setExpanded] = useState(false);
// Group content by type
const { errors, diffs } = groupContent(content);
@@ -69,46 +67,66 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const fileName = path ? getFileName(path) : '';
return (
<ToolCallContainer
label={fileName ? `Edit ${fileName}` : 'Edit'}
label={fileName ? 'Edit' : 'Edit'}
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{errors.join('\n')}
</ToolCallContainer>
);
}
// Success case with diff: show collapsible format
// Success case with diff: show minimal inline preview; clicking the title opens VS Code diff
if (diffs.length > 0) {
const firstDiff = diffs[0];
const path = firstDiff.path || (locations && locations[0]?.path) || '';
const fileName = path ? getFileName(path) : '';
// const fileName = path ? getFileName(path) : '';
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
// No hooks here; define a simple click handler scoped to this block
const openFirstDiff = () =>
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
return (
<div>
<div
className="relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)]"
onClick={() => setExpanded(!expanded)}
onClick={openFirstDiff}
title="Open diff in VS Code"
>
<span className="absolute left-2 top-[10px] text-[10px] text-[#74c991]">
</span>
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] max-w-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
Edit {fileName}
Edit
</span>
{toolCallId && (
{path && (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
)}
{/* {toolCallId && (
<span className="text-[10px] opacity-30">
[{toolCallId.slice(-8)}]
</span>
)}
)} */}
</div>
<span className="text-xs opacity-60 mr-2">
{expanded ? '▼' : '▶'}
</span>
<span className="text-xs opacity-60 ml-2">open</span>
</div>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
@@ -116,26 +134,26 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
</div>
</div>
</div>
{expanded && (
<div className="ml-[30px] mt-1">
{diffs.map(
(
item: import('./shared/types.js').ToolCallContent,
idx: number,
) => (
<DiffDisplay
key={`diff-${idx}`}
path={item.path}
oldText={item.oldText}
newText={item.newText}
onOpenDiff={() =>
handleOpenDiff(item.path, item.oldText, item.newText)
}
/>
),
)}
</div>
)}
{/* Content area aligned with bullet indent. Do NOT exceed container width. */}
{/* For any custom blocks here, keep: min-w-0 max-w-full and avoid extra horizontal padding/margins. */}
<div className="pl-[30px] mt-1 min-w-0 max-w-full overflow-hidden">
{diffs.map(
(
item: import('./shared/types.js').ToolCallContent,
idx: number,
) => (
<DiffDisplay
key={`diff-${idx}`}
path={item.path}
oldText={item.oldText}
newText={item.newText}
onOpenDiff={() =>
handleOpenDiff(item.path, item.oldText, item.newText)
}
/>
),
)}
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js';
import { FileLink } from '../shared/FileLink.js';
/**
* Specialized component for Read tool calls
@@ -23,17 +24,25 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { errors } = groupContent(content);
// Extract filename from path
const getFileName = (path: string): string => path.split('/').pop() || path;
// const getFileName = (path: string): string => path.split('/').pop() || path;
// Error case: show error
if (errors.length > 0) {
const path = locations?.[0]?.path || '';
const fileName = path ? getFileName(path) : '';
return (
<ToolCallContainer
label={fileName ? `Read ${fileName}` : 'Read'}
label={'Read'}
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{errors.join('\n')}
</ToolCallContainer>
@@ -42,12 +51,21 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success case: show which file was read with filename in label
if (locations && locations.length > 0) {
const fileName = getFileName(locations[0].path);
const path = locations[0].path;
return (
<ToolCallContainer
label={`Read ${fileName}`}
label={'Read'}
status="success"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{null}
</ToolCallContainer>

View File

@@ -9,7 +9,69 @@
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js';
import { groupContent, safeTitle } from './shared/utils.js';
import { CheckboxDisplay } from '../ui/CheckboxDisplay.js';
type EntryStatus = 'pending' | 'in_progress' | 'completed';
interface TodoEntry {
content: string;
status: EntryStatus;
}
const mapToolStatusToBullet = (
status: import('./shared/types.js').ToolCallStatus,
): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
switch (status) {
case 'completed':
return 'success';
case 'failed':
return 'error';
case 'in_progress':
return 'warning';
case 'pending':
return 'loading';
default:
return 'default';
}
};
// 从文本中尽可能解析带有 - [ ] / - [x] 的 todo 列表
const parseTodoEntries = (textOutputs: string[]): TodoEntry[] => {
const text = textOutputs.join('\n');
const lines = text.split(/\r?\n/);
const entries: TodoEntry[] = [];
const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.*)$/;
for (const line of lines) {
const m = line.match(todoRe);
if (m) {
const mark = m[1];
const title = m[2].trim();
const status: EntryStatus =
mark === 'x' || mark === 'X'
? 'completed'
: mark === '-'
? 'in_progress'
: 'pending';
if (title) {
entries.push({ content: title, status });
}
}
}
// 如果没匹配到,退化为将非空行当作 pending 条目
if (entries.length === 0) {
for (const line of lines) {
const title = line.trim();
if (title) {
entries.push({ content: title, status: 'pending' });
}
}
}
return entries;
};
/**
* Specialized component for TodoWrite tool calls
@@ -18,12 +80,10 @@ import { groupContent } from './shared/utils.js';
export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
toolCall,
}) => {
const { content } = toolCall;
// Group content by type
const { content, status } = toolCall;
const { errors, textOutputs } = groupContent(content);
// Error case: show error
// 错误优先展示
if (errors.length > 0) {
return (
<ToolCallContainer label="Update Todos" status="error">
@@ -32,17 +92,45 @@ export const TodoWriteToolCall: React.FC<BaseToolCallProps> = ({
);
}
// Success case: show simple confirmation
const outputText =
textOutputs.length > 0 ? textOutputs.join(' ') : 'Todos updated';
const entries = parseTodoEntries(textOutputs);
// Truncate if too long
const displayText =
outputText.length > 100 ? outputText.substring(0, 100) + '...' : outputText;
const label = safeTitle(toolCall.title) || 'Update Todos';
return (
<ToolCallContainer label="Update Todos" status="success">
{displayText}
<ToolCallContainer label={label} status={mapToolStatusToBullet(status)}>
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
{entries.map((entry, idx) => {
const isDone = entry.status === 'completed';
const isIndeterminate = entry.status === 'in_progress';
return (
<li
key={idx}
className={[
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
isDone ? 'fo opacity-70' : '',
].join(' ')}
>
<label className="flex items-start gap-2">
<CheckboxDisplay
checked={isDone}
indeterminate={isIndeterminate}
/>
</label>
<div
className={[
'vo flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
isDone
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
: 'opacity-85',
].join(' ')}
>
{entry.content}
</div>
</li>
);
})}
</ul>
</ToolCallContainer>
);
};

View File

@@ -10,6 +10,7 @@ import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js';
import { FileLink } from '../shared/FileLink.js';
/**
* Specialized component for Write tool calls
@@ -22,7 +23,7 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { errors, textOutputs } = groupContent(content);
// Extract filename from path
const getFileName = (path: string): string => path.split('/').pop() || path;
// const getFileName = (path: string): string => path.split('/').pop() || path;
// Extract content to write from rawInput
let writeContent = '';
@@ -36,7 +37,6 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Error case: show filename + error message + content preview
if (errors.length > 0) {
const path = locations?.[0]?.path || '';
const fileName = path ? getFileName(path) : '';
const errorMessage = errors.join('\n');
// Truncate content preview
@@ -47,9 +47,18 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
return (
<ToolCallContainer
label={fileName ? `Write ${fileName}` : 'Write'}
label={'Write'}
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
@@ -68,13 +77,22 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success case: show filename + line count
if (locations && locations.length > 0) {
const fileName = getFileName(locations[0].path);
const path = locations[0].path;
const lineCount = writeContent.split('\n').length;
return (
<ToolCallContainer
label={`Created ${fileName}`}
label={'Created'}
status="success"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>

View File

@@ -20,7 +20,7 @@
紧凑视图样式 - 超简洁版本
======================================== */
.diff-compact-view {
.diff-display-container {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-editor-background);
@@ -97,7 +97,7 @@
}
.diff-compact-actions {
padding: 4px 10px 6px;
padding: 6px 10px 8px;
border-top: 1px solid var(--vscode-panel-border);
background: var(--vscode-editorGroupHeader-tabsBackground);
display: flex;
@@ -108,19 +108,16 @@
完整视图样式
======================================== */
.diff-full-view {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
overflow: hidden;
}
/* 已移除完整视图,统一为简洁模式 + 预览 */
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--vscode-editorGroupHeader-tabsBackground);
border-bottom: 1px solid var(--vscode-panel-border);
/* 预览区域(仅变更行) */
.diff-preview {
margin: 0;
padding: 8px 10px;
background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.06));
border-top: 1px solid var(--vscode-panel-border);
max-height: 320px;
overflow: auto;
}
.diff-file-path {
@@ -133,12 +130,32 @@
gap: 8px;
}
.diff-stats-line {
padding: 8px 12px;
background: var(--vscode-editor-background);
.diff-line {
white-space: pre;
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
font-size: 0.88em;
line-height: 1.45;
}
.diff-line.added {
background: var(--vscode-diffEditor-insertedLineBackground, rgba(76, 175, 80, 0.18));
color: var(--vscode-diffEditor-insertedTextForeground, #b5f1cc);
}
.diff-line.removed {
background: var(--vscode-diffEditor-removedLineBackground, rgba(244, 67, 54, 0.18));
color: var(--vscode-diffEditor-removedTextForeground, #f6b1a7);
}
.diff-line.no-change {
color: var(--vscode-descriptionForeground);
font-size: 0.9em;
border-bottom: 1px solid var(--vscode-panel-border);
opacity: 0.8;
}
.diff-omitted {
color: var(--vscode-descriptionForeground);
font-style: italic;
padding-top: 6px;
}
.diff-section {
@@ -250,16 +267,6 @@
.diff-stats {
align-self: flex-start;
}
.diff-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.diff-header-actions {
align-self: flex-end;
}
}
/* ========================================

View File

@@ -7,7 +7,7 @@
*/
import type React from 'react';
import { useState, useMemo } from 'react';
import { useMemo } from 'react';
import { FileLink } from '../../shared/FileLink.js';
import {
calculateDiffStats,
@@ -15,6 +15,11 @@ import {
} from '../../../utils/diffStats.js';
import { OpenDiffIcon } from '../../icons/index.js';
import './DiffDisplay.css';
import {
computeLineDiff,
truncateOps,
type DiffOp,
} from '../../../utils/simpleDiff.js';
/**
* Props for DiffDisplay
@@ -24,8 +29,8 @@ interface DiffDisplayProps {
oldText?: string | null;
newText?: string;
onOpenDiff?: () => void;
/** 默认显示模式:'compact' | 'full' */
defaultMode?: 'compact' | 'full';
/** 是否显示统计信息 */
showStats?: boolean;
}
/**
@@ -37,20 +42,27 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
oldText,
newText,
onOpenDiff,
defaultMode = 'compact',
showStats = true,
}) => {
// 视图模式状态:紧凑或完整
const [viewMode, setViewMode] = useState<'compact' | 'full'>(defaultMode);
// 计算 diff 统计信息(仅在文本变化时重新计算)
// 统计信息(仅在文本变化时重新计算)
const stats = useMemo(
() => calculateDiffStats(oldText, newText),
[oldText, newText],
);
// 渲染紧凑视图
const renderCompactView = () => (
<div className="diff-compact-view">
// 仅生成变更行(增加/删除),不渲染上下文
const ops: DiffOp[] = useMemo(
() => computeLineDiff(oldText, newText),
[oldText, newText],
);
const {
items: previewOps,
truncated,
omitted,
} = useMemo(() => truncateOps<DiffOp>(ops), [ops]);
return (
<div className="diff-display-container">
<div
className="diff-compact-clickable"
onClick={onOpenDiff}
@@ -75,87 +87,74 @@ export const DiffDisplay: React.FC<DiffDisplayProps> = ({
/>
</div>
)}
<div className="diff-stats">
{stats.added > 0 && (
<span className="stat-added">+{stats.added}</span>
)}
{stats.removed > 0 && (
<span className="stat-removed">-{stats.removed}</span>
)}
{stats.changed > 0 && (
<span className="stat-changed">~{stats.changed}</span>
)}
{stats.total === 0 && (
<span className="stat-no-change">No changes</span>
)}
</div>
</div>
</div>
<div className="diff-compact-actions">
<button
className="diff-action-button secondary"
onClick={(e) => {
e.stopPropagation();
setViewMode('full');
}}
title="Show full before/after content"
>
Show Details
</button>
</div>
</div>
);
// 渲染完整视图
const renderFullView = () => (
<div className="diff-full-view">
<div className="diff-header">
<div className="diff-file-path">
{path && <FileLink path={path} showFullPath={true} />}
</div>
<div className="diff-header-actions">
{onOpenDiff && (
<button
className="diff-action-button primary"
onClick={onOpenDiff}
title="Open in VS Code diff viewer"
>
<OpenDiffIcon width="14" height="14" />
Open Diff
</button>
{showStats && (
<div className="diff-stats" title={formatDiffStatsDetailed(stats)}>
{stats.added > 0 && (
<span className="stat-added">+{stats.added}</span>
)}
{stats.removed > 0 && (
<span className="stat-removed">-{stats.removed}</span>
)}
{stats.changed > 0 && (
<span className="stat-changed">~{stats.changed}</span>
)}
{stats.total === 0 && (
<span className="stat-no-change">No changes</span>
)}
</div>
)}
</div>
</div>
{/* 只绘制差异行的预览区域 */}
<pre className="diff-preview code-block" aria-label="Diff preview">
<div className="code-content">
{previewOps.length === 0 && (
<div className="diff-line no-change">(no changes)</div>
)}
{previewOps.map((op, idx) => {
if (op.type === 'add') {
const line = op.line;
return (
<div key={`add-${idx}`} className="diff-line added">
+{line || ' '}
</div>
);
}
if (op.type === 'remove') {
const line = op.line;
return (
<div key={`rm-${idx}`} className="diff-line removed">
-{line || ' '}
</div>
);
}
return null;
})}
{truncated && (
<div
className="diff-omitted"
title={`${omitted} lines omitted in preview`}
>
{omitted} lines omitted
</div>
)}
</div>
</pre>
{/* 在预览下方提供显式打开按钮(可选) */}
{onOpenDiff && (
<div className="diff-compact-actions">
<button
className="diff-action-button secondary"
onClick={() => setViewMode('compact')}
title="Collapse to compact view"
className="diff-action-button primary"
onClick={onOpenDiff}
title="Open in VS Code diff viewer"
>
Collapse
<OpenDiffIcon width="14" height="14" />
Open Diff
</button>
</div>
</div>
<div className="diff-stats-line">{formatDiffStatsDetailed(stats)}</div>
{oldText !== undefined && (
<div className="diff-section">
<div className="diff-label">Before:</div>
<pre className="code-block">
<div className="code-content">{oldText || '(empty)'}</div>
</pre>
</div>
)}
{newText !== undefined && (
<div className="diff-section">
<div className="diff-label">After:</div>
<pre className="code-block">
<div className="code-content">{newText}</div>
</pre>
</div>
)}
</div>
);
return (
<div className="diff-display-container">
{viewMode === 'compact' ? renderCompactView() : renderFullView()}
</div>
);
};

View File

@@ -22,6 +22,8 @@ interface ToolCallContainerProps {
children: React.ReactNode;
/** Tool call ID for debugging */
toolCallId?: string;
/** Optional trailing content rendered next to label (e.g., clickable filename) */
labelSuffix?: React.ReactNode;
}
/**
@@ -51,24 +53,30 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label,
status = 'success',
children,
toolCallId,
toolCallId: _toolCallId,
labelSuffix,
}) => (
<div className="relative pl-[30px] py-2 select-text">
<span
className={`absolute left-2 top-[10px] text-[10px] ${getBulletColorClass(status)}`}
>
</span>
<div className="toolcall-content-wrapper flex flex-col gap-1 pl-[30px] max-w-full">
<div className="flex items-center gap-2">
<div className="relative pl-[30px] py-2 select-text toolcall-container">
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center gap-2 relative min-w-0">
{/* Status icon (bullet), vertically centered with header row */}
<span
aria-hidden
className={`absolute -left-[20px] top-1/2 -translate-y-1/2 text-[10px] leading-none ${getBulletColorClass(
status,
)}`}
>
</span>
<span className="text-[13px] font-medium text-[var(--app-primary-foreground)]">
{label}
</span>
{toolCallId && (
{/* {toolCallId && (
<span className="text-[10px] opacity-30">
[{toolCallId.slice(-8)}]
</span>
)}
)} */}
{labelSuffix}
</div>
{children && (
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
@@ -92,7 +100,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({
icon: _icon,
children,
}) => (
<div className="ml-[30px] grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in]">
<div className="grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in] toolcall-card">
<div className="flex flex-col gap-medium min-w-0">{children}</div>
</div>
);
@@ -195,7 +203,7 @@ interface LocationsListProps {
* List of file locations with clickable links
*/
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
<div className="toolcall-locations-list flex flex-col gap-1 pl-[30px] max-w-full">
<div className="toolcall-locations-list flex flex-col gap-1 max-w-full">
{locations.map((loc, idx) => (
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
))}

View File

@@ -48,6 +48,7 @@ export interface ToolCallData {
rawInput?: string | object;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
timestamp?: number; // 添加时间戳字段用于消息排序
}
/**

View File

@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
export interface CheckboxDisplayProps {
checked?: boolean;
indeterminate?: boolean;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
title?: string;
}
/**
* Display-only checkbox styled via Tailwind classes.
* - Renders a custom-looking checkbox using appearance-none and pseudo-elements.
* - Supports indeterminate (middle) state using the DOM property and a data- attribute.
* - Intended for read-only display (disabled by default).
*/
export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
checked = false,
indeterminate = false,
disabled = true,
className = '',
style,
title,
}) => {
const ref = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
el.indeterminate = !!indeterminate;
if (indeterminate) {
el.setAttribute('data-indeterminate', 'true');
} else {
el.removeAttribute('data-indeterminate');
}
}, [indeterminate, checked]);
return (
<input
ref={ref}
type="checkbox"
disabled={disabled}
checked={checked}
readOnly
aria-checked={indeterminate ? 'mixed' : checked}
title={title}
style={style}
className={[
// Base box style (equivalent to .q)
'q appearance-none m-[2px] shrink-0 w-4 h-4 relative rounded-[2px] box-border',
'border border-[var(--app-input-border)] bg-[var(--app-input-background)] text-[var(--app-primary-foreground)]',
'inline-flex items-center justify-center',
// Checked visual state
'checked:opacity-70 checked:text-[#74c991]',
// Checkmark / indeterminate symbol via pseudo-element
'after:absolute after:left-1/2 after:top-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:opacity-0 after:pointer-events-none after:antialiased',
'checked:after:content-["\\2713"] checked:after:text-[0.9em] checked:after:opacity-100',
'data-[indeterminate=true]:text-[#e1c08d] data-[indeterminate=true]:after:content-["\\273d"] data-[indeterminate=true]:after:text-[0.8em] data-[indeterminate=true]:after:opacity-100',
className,
].join(' ')}
/>
);
};