Files
qwen-code/packages/vscode-ide-companion/src/webview/components/InputForm.tsx
yiliang114 9cc48f12da feat(vscode-ide-companion): 改进消息排序和显示逻辑
- 添加时间戳支持,确保消息按时间顺序排列
- 更新工具调用处理逻辑,自动添加和保留时间戳
- 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染
- 优化完成的工具调用显示,修复显示顺序问题
- 调整进行中的工具调用显示,统一到消息流中展示
- 移除重复的计划展示逻辑,避免最新块重复出现
- 重构消息处理和渲染代码,提高可维护性
2025-11-28 09:55:06 +08:00

296 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import {
EditPencilIcon,
AutoEditIcon,
PlanModeIcon,
CodeBracketsIcon,
ThinkingIcon,
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
} from './icons/index.js';
import { ClaudeCompletionMenu } from './ClaudeCompletionMenu.js';
import type { CompletionItem } from './CompletionMenu.js';
type EditMode = 'ask' | 'auto' | 'plan';
interface InputFormProps {
inputText: string;
// Note: RefObject<T> carries nullability in its `current` property, so the
// generic should be `HTMLDivElement` (not `HTMLDivElement | null`).
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
isComposing: boolean;
editMode: EditMode;
thinkingEnabled: boolean;
activeFileName: string | null;
activeSelection: { startLine: number; endLine: number } | null;
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onSubmit: (e: React.FormEvent) => void;
onToggleEditMode: () => void;
onToggleThinking: () => void;
onFocusActiveEditor: () => void;
onShowCommandMenu: () => void;
onAttachContext: () => void;
completionIsOpen: boolean;
completionItems?: CompletionItem[];
onCompletionSelect?: (item: CompletionItem) => void;
onCompletionClose?: () => void;
}
// Get edit mode display info
const getEditModeInfo = (editMode: EditMode) => {
switch (editMode) {
case 'ask':
return {
text: 'Ask before edits',
title: 'Qwen will ask before each edit. Click to switch modes.',
icon: <EditPencilIcon />,
};
case 'auto':
return {
text: 'Edit automatically',
title: 'Qwen will edit files automatically. Click to switch modes.',
icon: <AutoEditIcon />,
};
case 'plan':
return {
text: 'Plan mode',
title: 'Qwen will plan before executing. Click to switch modes.',
icon: <PlanModeIcon />,
};
default:
return {
text: 'Unknown mode',
title: 'Unknown edit mode',
icon: null,
};
}
};
export const InputForm: React.FC<InputFormProps> = ({
inputText,
inputFieldRef,
isStreaming,
isComposing,
editMode,
thinkingEnabled,
activeFileName,
activeSelection,
onInputChange,
onCompositionStart,
onCompositionEnd,
onKeyDown,
onSubmit,
onToggleEditMode,
onToggleThinking,
onFocusActiveEditor,
onShowCommandMenu,
onAttachContext,
completionIsOpen,
// Claude-style completion dropdown (optional)
completionItems,
onCompletionSelect,
onCompletionClose,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const handleKeyDown = (e: React.KeyboardEvent) => {
// If composing (Chinese IME input), don't process Enter key
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
// If CompletionMenu is open, let it handle Enter key
if (completionIsOpen) {
return;
}
e.preventDefault();
onSubmit(e);
}
onKeyDown(e);
};
return (
<div
className="p-1 px-4 pb-4"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
<form
className="relative flex flex-col rounded-large border shadow-sm transition-all duration-200 focus-within:shadow-md"
style={{
backgroundColor:
'var(--app-input-secondary-background, var(--app-input-background))',
borderColor: 'var(--app-input-border)',
color: 'var(--app-input-foreground)',
}}
onSubmit={onSubmit}
>
{/* Inner background layer */}
<div
className="absolute inset-0 rounded-large z-0"
style={{ backgroundColor: 'var(--app-input-background)' }}
/>
{/* Banner area */}
<div className="input-banner" />
{/* 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="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)',
}}
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Qwen Code …"
onInput={(e) => {
const target = e.target as HTMLDivElement;
onInputChange(target.textContent || '');
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
suppressContentEditableWarning
/>
</div>
{/* Actions row */}
<div
className="flex items-center p-1.5 gap-1.5 min-w-0 z-[1]"
style={{
color: 'var(--app-secondary-foreground)',
borderTop: '0.5px solid var(--app-input-border)',
}}
>
{/* Edit mode button */}
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0"
style={{ color: 'var(--app-primary-foreground)' }}
title={editModeInfo.title}
onClick={onToggleEditMode}
>
{editModeInfo.icon}
<span>{editModeInfo.text}</span>
</button>
{/* Active file indicator */}
{activeFileName && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0 max-w-[200px] overflow-hidden text-ellipsis flex-shrink min-w-0"
style={{ color: 'var(--app-primary-foreground)' }}
title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`}
onClick={onFocusActiveEditor}
>
<CodeBracketsIcon />
<span>
{activeFileName}
{activeSelection &&
` #${activeSelection.startLine}${activeSelection.startLine !== activeSelection.endLine ? `-${activeSelection.endLine}` : ''}`}
</span>
</button>
)}
{/* Divider */}
<div
className="w-px h-6 mx-0.5 flex-shrink-0"
style={{
backgroundColor: 'var(--app-transparent-inner-border)',
}}
/>
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* Thinking button */}
<button
type="button"
className={`flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 ${
thinkingEnabled
? 'bg-qwen-clay-orange text-qwen-ivory [&>svg]:stroke-qwen-ivory [&>svg]:fill-qwen-ivory'
: ''
}`}
style={{
color: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
backgroundColor: thinkingEnabled
? 'var(--app-qwen-clay-button-orange)'
: undefined,
}}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<ThinkingIcon enabled={thinkingEnabled} />
</button>
{/* Command button */}
<button
type="button"
className="flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] hover:text-[var(--app-primary-foreground)] [&>svg]:w-4 [&>svg]:h-4"
style={{ color: 'var(--app-secondary-foreground)' }}
title="Show command menu (/)"
onClick={onShowCommandMenu}
>
<SlashCommandIcon />
</button>
{/* Attach button */}
<button
type="button"
className="flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] hover:text-[var(--app-primary-foreground)] [&>svg]:w-4 [&>svg]:h-4"
style={{ color: 'var(--app-secondary-foreground)' }}
title="Attach context (Cmd/Ctrl + /)"
onClick={onAttachContext}
>
<LinkIcon />
</button>
{/* Send button */}
<button
type="submit"
className="flex items-center justify-center w-8 h-8 p-0 border border-transparent rounded-small cursor-pointer transition-all duration-150 ml-auto flex-shrink-0 hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed [&>svg]:w-5 [&>svg]:h-5"
style={{
backgroundColor: 'var(--app-qwen-clay-button-orange)',
color: 'var(--app-qwen-ivory)',
}}
disabled={isStreaming || !inputText.trim()}
>
<ArrowUpIcon />
</button>
</div>
</form>
</div>
</div>
);
};