feat(ui): 重构模式选择器为点击切换交互

- 将 Build/Plan 下拉框和 Auto-approve 开关合并为三种模式
- Plan: 调用 plan agent,只读分析
- Ask: 调用 general agent,执行操作前需确认
- Auto: 调用 general agent,自动执行无需确认
- 点击按钮即可循环切换:Ask → Auto → Plan
- 每种模式有独特的颜色和图标便于区分
This commit is contained in:
2025-12-17 17:18:58 +08:00
parent fc75fcfc90
commit 619cd2d2dd
2 changed files with 213 additions and 212 deletions
+106 -139
View File
@@ -1,16 +1,20 @@
/** /**
* Agent Mode Selector Component * Agent Mode Selector Component
* *
* 在 ChatInput 左侧显示,用于切换 Build/Plan 模式和控制 Auto-approve * 在 ChatInput 左侧显示,点击切换三种模式:
* - Plan: 调用 plan agent,只读分析
* - Ask: 调用 general agent,执行操作需确认
* - Auto: 调用 general agent,自动执行无需确认
*/ */
import { useState, useRef, useEffect } from 'react'; import { FileSearch, MessageCircleQuestion, Zap } from 'lucide-react';
import { Hammer, FileSearch, ChevronDown, Check } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Switch } from '../primitives/Switch.js';
export type AgentModeType = 'build' | 'plan'; export type AgentModeType = 'build' | 'plan';
/** 组合模式:Plan / Ask / Auto */
export type CombinedModeType = 'plan' | 'ask' | 'auto';
interface AgentModeSelectorProps { interface AgentModeSelectorProps {
/** 当前模式 */ /** 当前模式 */
mode: AgentModeType; mode: AgentModeType;
@@ -24,23 +28,75 @@ interface AgentModeSelectorProps {
disabled?: boolean; disabled?: boolean;
} }
const modeConfig = { const modeConfig: Record<
build: { CombinedModeType,
label: 'Build', {
icon: Hammer, label: string;
color: 'text-blue-500', icon: typeof FileSearch;
bgColor: 'bg-blue-500/10', color: string;
description: '可执行代码修改', bgColor: string;
}, borderColor: string;
hoverBg: string;
description: string;
}
> = {
plan: { plan: {
label: 'Plan', label: 'Plan',
icon: FileSearch, icon: FileSearch,
color: 'text-purple-500', color: 'text-purple-500',
bgColor: 'bg-purple-500/10', bgColor: 'bg-purple-500/10',
description: '只读模式,仅分析', borderColor: 'border-purple-500/30',
hoverBg: 'hover:bg-purple-500/15',
description: '只读模式,仅分析和规划',
},
ask: {
label: 'Ask',
icon: MessageCircleQuestion,
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
borderColor: 'border-blue-500/30',
hoverBg: 'hover:bg-blue-500/15',
description: '执行操作前需要确认',
},
auto: {
label: 'Auto',
icon: Zap,
color: 'text-orange-500',
bgColor: 'bg-orange-500/10',
borderColor: 'border-orange-500/30',
hoverBg: 'hover:bg-orange-500/15',
description: '自动执行,无需确认',
}, },
}; };
// 模式切换顺序:Ask -> Auto -> Plan -> Ask ...
const modeOrder: CombinedModeType[] = ['ask', 'auto', 'plan'];
/**
* 根据 agentMode 和 autoApprove 计算当前组合模式
*/
function getCombinedMode(agentMode: AgentModeType, autoApprove: boolean): CombinedModeType {
if (agentMode === 'plan') return 'plan';
return autoApprove ? 'auto' : 'ask';
}
/**
* 根据组合模式计算 agentMode 和 autoApprove
*/
function getAgentConfig(combinedMode: CombinedModeType): {
agentMode: AgentModeType;
autoApprove: boolean;
} {
switch (combinedMode) {
case 'plan':
return { agentMode: 'plan', autoApprove: false };
case 'ask':
return { agentMode: 'build', autoApprove: false };
case 'auto':
return { agentMode: 'build', autoApprove: true };
}
}
export function AgentModeSelector({ export function AgentModeSelector({
mode, mode,
onModeChange, onModeChange,
@@ -48,138 +104,49 @@ export function AgentModeSelector({
onAutoApproveChange, onAutoApproveChange,
disabled = false, disabled = false,
}: AgentModeSelectorProps) { }: AgentModeSelectorProps) {
const [isOpen, setIsOpen] = useState(false); // 计算当前组合模式
const dropdownRef = useRef<HTMLDivElement>(null); const currentCombinedMode = getCombinedMode(mode, autoApprove);
const currentConfig = modeConfig[currentCombinedMode];
const ModeIcon = currentConfig.icon;
const currentMode = modeConfig[mode]; // 点击切换到下一个模式
const ModeIcon = currentMode.icon; const handleToggle = () => {
if (disabled) return;
// 点击外部关闭下拉菜单 // 找到当前模式在顺序中的索引
useEffect(() => { const currentIndex = modeOrder.indexOf(currentCombinedMode);
function handleClickOutside(event: MouseEvent) { // 切换到下一个模式
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { const nextIndex = (currentIndex + 1) % modeOrder.length;
setIsOpen(false); const nextMode = modeOrder[nextIndex];
}
}
if (isOpen) { // 获取新的配置并更新
document.addEventListener('mousedown', handleClickOutside); const { agentMode, autoApprove: newAutoApprove } = getAgentConfig(nextMode);
return () => document.removeEventListener('mousedown', handleClickOutside); onModeChange(agentMode);
} onAutoApproveChange(newAutoApprove);
}, [isOpen]);
// 键盘导航
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === 'Escape') {
setIsOpen(false);
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
// 切换模式
onModeChange(mode === 'build' ? 'plan' : 'build');
} else if (event.key === 'Enter') {
setIsOpen(false);
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, mode, onModeChange]);
const handleModeSelect = (newMode: AgentModeType) => {
onModeChange(newMode);
setIsOpen(false);
}; };
return ( return (
<div className="flex items-center gap-2"> <button
{/* 模式选择器 */} type="button"
<div ref={dropdownRef} className="relative"> onClick={handleToggle}
<button disabled={disabled}
type="button" title={`${currentConfig.label}: ${currentConfig.description}(点击切换)`}
onClick={() => !disabled && setIsOpen(!isOpen)} className={clsx(
disabled={disabled} 'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-200',
className={clsx( 'text-sm font-medium',
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-colors', disabled
'text-sm font-medium', ? 'opacity-50 cursor-not-allowed border-line bg-surface-subtle'
disabled : [
? 'opacity-50 cursor-not-allowed border-line bg-surface-subtle' currentConfig.bgColor,
: 'border-line hover:border-line-strong hover:bg-surface-subtle cursor-pointer', currentConfig.borderColor,
currentMode.color currentConfig.hoverBg,
)} 'cursor-pointer active:scale-95',
> ],
<ModeIcon size={16} /> currentConfig.color
<span className="hidden sm:inline">{currentMode.label}</span>
<ChevronDown
size={14}
className={clsx(
'transition-transform text-fg-muted',
isOpen && 'rotate-180'
)}
/>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className="absolute bottom-full left-0 mb-1 w-48 py-1 bg-surface-base border border-line rounded-lg shadow-lg z-50">
{(Object.entries(modeConfig) as [AgentModeType, typeof modeConfig.build][]).map(
([modeKey, config]) => {
const Icon = config.icon;
const isSelected = modeKey === mode;
return (
<button
key={modeKey}
type="button"
onClick={() => handleModeSelect(modeKey)}
className={clsx(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? `${config.bgColor} ${config.color}`
: 'hover:bg-surface-subtle text-fg'
)}
>
<Icon size={16} className={config.color} />
<div className="flex-1">
<div className="text-sm font-medium">{config.label}</div>
<div className="text-xs text-fg-muted">{config.description}</div>
</div>
{isSelected && <Check size={16} className={config.color} />}
</button>
);
}
)}
</div>
)}
</div>
{/* Auto-approve 开关 - 仅在 Build 模式下显示 */}
{mode === 'build' && (
<div
className={clsx(
'flex items-center gap-1.5 px-2 py-1 rounded-lg transition-colors',
autoApprove ? 'bg-orange-500/10' : 'bg-transparent'
)}
title={autoApprove ? '自动授权已开启:文件写入/编辑无需确认' : '点击开启自动授权'}
>
<span
className={clsx(
'text-xs hidden sm:inline transition-colors',
autoApprove ? 'text-orange-500' : 'text-fg-muted'
)}
>
Auto Edit
</span>
<Switch
checked={autoApprove}
onCheckedChange={onAutoApproveChange}
disabled={disabled}
className="scale-75 origin-left"
/>
</div>
)} )}
</div> >
<ModeIcon size={16} />
<span className="hidden sm:inline">{currentConfig.label}</span>
</button>
); );
} }
+107 -73
View File
@@ -7,7 +7,8 @@
*/ */
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Send, Square } from 'lucide-react'; import { Square, Sparkles } from 'lucide-react';
import { motion } from 'framer-motion';
import clsx from 'clsx'; import clsx from 'clsx';
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js'; import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
import { FileMenu, type FileMenuItem } from './FileMenu.js'; import { FileMenu, type FileMenuItem } from './FileMenu.js';
@@ -247,7 +248,7 @@ export function ChatInput({
return ( return (
<div <div
className={clsx( className={clsx(
'border-t border-line bg-surface-base relative', 'bg-surface-base relative',
responsive ? 'p-3 md:p-4 safe-area-pb' : 'p-4' responsive ? 'p-3 md:p-4 safe-area-pb' : 'p-4'
)} )}
> >
@@ -278,83 +279,116 @@ export function ChatInput({
)} )}
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* 已选文件标签 */} {/* 主输入容器 - 现代化卡片设计 */}
{mentionedFiles.length > 0 && ( <div
<div className="flex flex-wrap gap-1.5 mb-2"> className={clsx(
{mentionedFiles.map((file, index) => ( 'relative rounded-2xl border transition-all duration-200',
<FileMentionTag 'bg-surface-subtle',
key={`${file}-${index}`} disabled
path={file} ? 'border-line opacity-60'
size="sm" : 'border-line hover:border-fg-subtle/30 focus-within:border-primary-500 focus-within:shadow-lg focus-within:shadow-primary-500/10'
removable )}
onRemove={() => handleRemoveFile(file)} >
/> {/* 已选文件标签 */}
))} {mentionedFiles.length > 0 && (
</div> <div className="flex flex-wrap gap-1.5 px-4 pt-3">
)} {mentionedFiles.map((file, index) => (
<FileMentionTag
{/* 输入区域 */} key={`${file}-${index}`}
<div className="flex gap-2 items-end"> path={file}
{/* Agent 模式选择器 */} size="sm"
{onAgentModeChange && ( removable
<div className="flex-shrink-0 pb-1.5"> onRemove={() => handleRemoveFile(file)}
<AgentModeSelector />
mode={agentMode} ))}
onModeChange={onAgentModeChange}
autoApprove={autoApprove}
onAutoApproveChange={onAutoApproveChange ?? (() => {})}
disabled={disabled || isLoading}
/>
</div> </div>
)} )}
<div className="flex-1 relative"> {/* 输入区域 */}
<textarea <div className="flex items-end gap-2 p-2">
ref={textareaRef} {/* Agent 模式选择器 */}
value={input} {onAgentModeChange && (
onChange={handleInputChange} <div className="flex-shrink-0 mb-1">
onKeyDown={handleKeyDown} <AgentModeSelector
placeholder={ mode={agentMode}
responsive onModeChange={onAgentModeChange}
? 'Type a message, /command, or @file...' autoApprove={autoApprove}
: 'Type a message, /command, or @file... (Shift+Enter for new line)' onAutoApproveChange={onAutoApproveChange ?? (() => {})}
} disabled={disabled || isLoading}
disabled={disabled} />
rows={1} </div>
className={clsx(
'w-full resize-none rounded-lg border border-line bg-surface-subtle',
responsive ? 'px-3 py-2.5 md:px-4 md:py-3' : 'px-4 py-3',
responsive ? 'text-base md:text-sm' : 'text-sm', // 移动端使用 16px 防止缩放
'text-fg placeholder-fg-subtle',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
/>
</div>
<button
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'rounded-lg flex items-center justify-center transition-colors self-end',
responsive
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
: 'px-4 py-3',
isLoading
? 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white'
: 'bg-primary-600 hover:bg-primary-700 active:bg-primary-800 text-white',
'disabled:opacity-50 disabled:cursor-not-allowed'
)} )}
>
{isLoading ? <Square size={20} /> : <Send size={20} />} <div className="flex-1 relative">
</button> <textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
responsive
? 'Ask anything...'
: 'Ask anything... (Shift+Enter for new line)'
}
disabled={disabled}
rows={1}
className={clsx(
'w-full resize-none bg-transparent border-0',
responsive ? 'px-2 py-2 md:px-3 md:py-2' : 'px-3 py-2',
responsive ? 'text-base md:text-sm' : 'text-sm',
'text-fg placeholder-fg-subtle/60',
'focus:outline-none focus:ring-0',
'disabled:cursor-not-allowed'
)}
/>
</div>
{/* 发送按钮 */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={isLoading ? onCancel : handleSubmit}
disabled={!isLoading && (!input.trim() || disabled)}
className={clsx(
'flex-shrink-0 rounded-xl flex items-center justify-center transition-all duration-200',
responsive
? 'w-10 h-10 md:w-9 md:h-9'
: 'w-9 h-9',
isLoading
? 'bg-red-500 hover:bg-red-600 text-white shadow-lg shadow-red-500/25'
: input.trim() && !disabled
? 'bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 text-white shadow-lg shadow-primary-500/25'
: 'bg-surface-muted text-fg-subtle',
'disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none'
)}
>
{isLoading ? (
<Square size={18} className="fill-current" />
) : (
<Sparkles size={18} className={clsx(input.trim() && !disabled && 'animate-pulse')} />
)}
</motion.button>
</div>
</div> </div>
{/* 底部提示 */}
{responsive && (
<div className="hidden md:flex items-center justify-center gap-4 mt-3 text-xs text-fg-subtle/60">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">Enter</kbd>
<span>send</span>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">/</kbd>
<span>commands</span>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-surface-subtle text-fg-muted font-mono text-[10px]">@</kbd>
<span>files</span>
</span>
</div>
)}
</div> </div>
{/* 响应式模式下桌面端显示提示文字 */}
{responsive && (
<p className="hidden md:block text-xs text-fg-subtle text-center mt-2">
Press Enter to send, Shift+Enter for new line, / for commands, @ for files
</p>
)}
</div> </div>
); );
} }