feat(desktop): simple mode UI — ChatArea compact + SimpleSidebar + RightPanel dual-mode

Adapt ChatArea for compact/butler mode:
- Add onOpenDetail prop for expanding to full view
- Remove inline export dialog (moved to detail view)
- Replace SquarePen with ClipboardList icon

Add SimpleSidebar component for butler simple mode:
- Two tabs: 对话 / 行业资讯
- Quick suggestion buttons
- Minimal navigation

RightPanel refactoring for dual-mode support:
- Detect simple vs professional mode
- Conditional rendering based on butler mode state
This commit is contained in:
iven
2026-04-09 17:48:18 +08:00
parent 4b15ead8e7
commit 2f25316e83
3 changed files with 213 additions and 93 deletions

View File

@@ -10,7 +10,7 @@ import { useConfigStore } from '../store/configStore';
import { useSaaSStore } from '../store/saasStore';
import { type UnlistenFn } from '@tauri-apps/api/event';
import { safeListenEvent } from '../lib/safe-tauri';
import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search } from 'lucide-react';
import { Paperclip, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search, ClipboardList } from 'lucide-react';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
@@ -49,11 +49,11 @@ const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
// Threshold for enabling virtualization (messages count)
const VIRTUALIZATION_THRESHOLD = 100;
export function ChatArea({ compact }: { compact?: boolean }) {
export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenDetail?: () => void }) {
const {
messages, isStreaming, isLoading,
sendMessage: sendToGateway, initStreamListener,
newConversation, chatMode, setChatMode, suggestions,
chatMode, setChatMode, suggestions,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const currentAgent = useConversationStore((s) => s.currentAgent);
@@ -239,23 +239,6 @@ export function ChatArea({ compact }: { compact?: boolean }) {
const connected = connectionState === 'connected';
// Export current conversation as Markdown
const exportCurrentConversation = () => {
const title = currentAgent?.name || 'ZCLAW 对话';
const lines = [`# ${title}`, '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, ''];
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role;
lines.push(`## ${label}`, '', msg.content, '');
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
// Build artifact panel content
const artifactRightPanel = (
<ArtifactPanel
@@ -364,28 +347,16 @@ export function ChatArea({ compact }: { compact?: boolean }) {
<Search className="w-3.5 h-3.5" />
</Button>
)}
{messages.length > 0 && (
{/* 详情按钮 (简洁模式) */}
{compact && onOpenDetail && (
<Button
variant="ghost"
size="sm"
onClick={exportCurrentConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="导出对话"
onClick={onOpenDetail}
className="flex items-center gap-1 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="详情"
>
<Download className="w-3.5 h-3.5" />
<span className="text-sm"></span>
</Button>
)}
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={newConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="新对话"
>
<SquarePen className="w-3.5 h-3.5" />
<ClipboardList className="w-3.5 h-3.5" />
</Button>
)}
</div>

View File

@@ -85,7 +85,11 @@ import { Button, Badge } from './ui';
import { getPersonalityById } from '../lib/personality-presets';
import { silentErrorHandler } from '../lib/error-utils';
export function RightPanel() {
interface RightPanelProps {
simpleMode?: boolean;
}
export function RightPanel({ simpleMode = false }: RightPanelProps) {
// Connection store
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
@@ -271,60 +275,85 @@ export function RightPanel() {
<aside className="w-full bg-white dark:bg-gray-900 flex flex-col">
{/* 顶部工具栏 - Tab 栏 */}
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
{/* 主 Tab 行 */}
<div className="flex items-center px-2 pt-2 gap-1">
<TabButton
active={activeTab === 'status'}
onClick={() => setActiveTab('status')}
icon={<Activity className="w-4 h-4" />}
label="状态"
/>
<TabButton
active={activeTab === 'agent'}
onClick={() => setActiveTab('agent')}
icon={<User className="w-4 h-4" />}
label="Agent"
/>
<TabButton
active={activeTab === 'files'}
onClick={() => setActiveTab('files')}
icon={<FileText className="w-4 h-4" />}
label="文件"
/>
<TabButton
active={activeTab === 'memory'}
onClick={() => setActiveTab('memory')}
icon={<Brain className="w-4 h-4" />}
label="记忆"
/>
</div>
{/* 第二行 Tab */}
<div className="flex items-center px-2 pb-2 gap-1">
<TabButton
active={activeTab === 'reflection'}
onClick={() => setActiveTab('reflection')}
icon={<Sparkles className="w-4 h-4" />}
label="反思"
/>
<TabButton
active={activeTab === 'autonomy'}
onClick={() => setActiveTab('autonomy')}
icon={<Shield className="w-4 h-4" />}
label="自主"
/>
<TabButton
active={activeTab === 'evolution'}
onClick={() => setActiveTab('evolution')}
icon={<Dna className="w-4 h-4" />}
label="演化"
/>
<TabButton
active={activeTab === 'butler'}
onClick={() => setActiveTab('butler')}
icon={<ConciergeBell className="w-4 h-4" />}
label="管家"
/>
</div>
{simpleMode ? (
/* 简洁模式: 仅 状态 / Agent / 管家 */
<div className="flex items-center px-2 py-2 gap-1">
<TabButton
active={activeTab === 'status'}
onClick={() => setActiveTab('status')}
icon={<Activity className="w-4 h-4" />}
label="状态"
/>
<TabButton
active={activeTab === 'agent'}
onClick={() => setActiveTab('agent')}
icon={<User className="w-4 h-4" />}
label="Agent"
/>
<TabButton
active={activeTab === 'butler'}
onClick={() => setActiveTab('butler')}
icon={<ConciergeBell className="w-4 h-4" />}
label="管家"
/>
</div>
) : (
<>
{/* 专业模式: 全部 8 个 Tab */}
<div className="flex items-center px-2 pt-2 gap-1">
<TabButton
active={activeTab === 'status'}
onClick={() => setActiveTab('status')}
icon={<Activity className="w-4 h-4" />}
label="状态"
/>
<TabButton
active={activeTab === 'agent'}
onClick={() => setActiveTab('agent')}
icon={<User className="w-4 h-4" />}
label="Agent"
/>
<TabButton
active={activeTab === 'files'}
onClick={() => setActiveTab('files')}
icon={<FileText className="w-4 h-4" />}
label="文件"
/>
<TabButton
active={activeTab === 'memory'}
onClick={() => setActiveTab('memory')}
icon={<Brain className="w-4 h-4" />}
label="记忆"
/>
</div>
<div className="flex items-center px-2 pb-2 gap-1">
<TabButton
active={activeTab === 'reflection'}
onClick={() => setActiveTab('reflection')}
icon={<Sparkles className="w-4 h-4" />}
label="反思"
/>
<TabButton
active={activeTab === 'autonomy'}
onClick={() => setActiveTab('autonomy')}
icon={<Shield className="w-4 h-4" />}
label="自主"
/>
<TabButton
active={activeTab === 'evolution'}
onClick={() => setActiveTab('evolution')}
icon={<Dna className="w-4 h-4" />}
label="演化"
/>
<TabButton
active={activeTab === 'butler'}
onClick={() => setActiveTab('butler')}
icon={<ConciergeBell className="w-4 h-4" />}
label="管家"
/>
</div>
</>
)}
</div>
{/* 消息统计 */}

View File

@@ -0,0 +1,120 @@
/**
* SimpleSidebar - Trae Solo 风格的简洁侧边栏
*
* 仅显示:对话列表 + 行业资讯
* 底部:模式切换 + 设置
*/
import { useState } from 'react';
import {
MessageSquare, Settings, LayoutGrid,
Search, X, Newspaper,
} from 'lucide-react';
import { ConversationList } from './ConversationList';
interface SimpleSidebarProps {
onOpenSettings?: () => void;
onToggleMode?: () => void;
}
type Tab = 'conversations' | 'news';
export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) {
const [activeTab, setActiveTab] = useState<Tab>('conversations');
const [searchQuery, setSearchQuery] = useState('');
return (
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0">
{/* Logo area */}
<div className="h-14 flex items-center px-4 border-b border-[#e8e6e1]/50 dark:border-gray-800">
<span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
ZCLAW
</span>
</div>
{/* Tab 切换: 对话 / 行业资讯 */}
<div className="flex border-b border-[#e8e6e1]/50 dark:border-gray-800">
<button
onClick={() => setActiveTab('conversations')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'conversations'
? 'text-gray-900 dark:text-gray-100 border-b-2 border-orange-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<MessageSquare className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setActiveTab('news')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
activeTab === 'news'
? 'text-gray-900 dark:text-gray-100 border-b-2 border-orange-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<Newspaper className="w-3.5 h-3.5" />
</button>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-hidden">
{activeTab === 'conversations' && (
<div className="p-2 h-full overflow-y-auto">
{/* 搜索框 */}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-8 py-1.5 bg-white/60 dark:bg-gray-800 border border-[#e8e6e1] dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:border-gray-400 transition-all text-gray-700 dark:text-gray-300 placeholder-gray-400"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded text-gray-400"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<ConversationList searchQuery={searchQuery} />
</div>
)}
{activeTab === 'news' && (
<div className="p-3 h-full overflow-y-auto">
<div className="flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500">
<Newspaper className="w-10 h-10 mb-3 opacity-50" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
</div>
)}
</div>
{/* 底部操作栏 */}
<div className="p-2 border-t border-[#e8e6e1] dark:border-gray-700 space-y-1">
<button
onClick={onToggleMode}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<LayoutGrid className="w-4 h-4" />
<span></span>
</button>
<button
onClick={onOpenSettings}
aria-label="打开设置"
title="设置和更多"
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<Settings className="w-4 h-4" />
<span></span>
</button>
</div>
</aside>
);
}