feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局

- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
This commit is contained in:
oudecheng 2026-08-14 19:15:45 +08:00
parent 4c21add43b
commit eaee29841d
30 changed files with 868 additions and 696 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { import {
Zap, Zap,
ArrowLeft, ArrowLeft,
@ -11,6 +11,8 @@ import {
Brain, Brain,
Settings as SettingsIcon, Settings as SettingsIcon,
ChevronRight, ChevronRight,
MessageSquare,
Calendar,
} from 'lucide-react'; } from 'lucide-react';
import { ChatContainer } from './components/Chat/ChatContainer'; import { ChatContainer } from './components/Chat/ChatContainer';
import { TopicList } from './components/Sidebar/TopicList'; import { TopicList } from './components/Sidebar/TopicList';
@ -200,6 +202,75 @@ function App() {
// 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态 // 设置弹窗关闭计数器:每次关闭时递增,用于通知 ExpertSelector 刷新已选专家状态
const [settingsClosedTick, setSettingsClosedTick] = useState(0); const [settingsClosedTick, setSettingsClosedTick] = useState(0);
// ---- 三栏列宽(拖拽调宽,对齐 harness AppFrame----
const [sidebarWidth, setSidebarWidth] = useState(() => {
const parsed = parseInt(localStorage.getItem('picobot-sidebar-width') ?? '', 10);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 216), 400) : 288;
});
const [rightPanelWidth, setRightPanelWidth] = useState(() => {
const parsed = parseInt(localStorage.getItem('picobot-right-panel-width') ?? '', 10);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 240), 420) : 320;
});
// 拖拽期间禁用 width transition避免面板"追鼠标"延迟感
const [isResizing, setIsResizing] = useState(false);
const startSidebarDrag = useCallback(
(e: ReactMouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = sidebarWidth;
setIsResizing(true);
const onMove = (ev: MouseEvent) => {
setSidebarWidth(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400));
};
const onUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setIsResizing(false);
localStorage.setItem(
'picobot-sidebar-width',
String(Math.min(Math.max(startWidth + ev.clientX - startX, 216), 400)),
);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[sidebarWidth],
);
const startRightPanelDrag = useCallback(
(e: ReactMouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = rightPanelWidth;
setIsResizing(true);
const onMove = (ev: MouseEvent) => {
setRightPanelWidth(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420));
};
const onUp = (ev: MouseEvent) => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setIsResizing(false);
localStorage.setItem(
'picobot-right-panel-width',
String(Math.min(Math.max(startWidth - (ev.clientX - startX), 240), 420)),
);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[rightPanelWidth],
);
const handleSaveConnection = useCallback((host: string, port: number) => { const handleSaveConnection = useCallback((host: string, port: number) => {
setGatewaySettings({ host, port }); setGatewaySettings({ host, port });
}, []); }, []);
@ -615,59 +686,112 @@ function App() {
}, [schedulerView, subAgentView, selectedTopic]); }, [schedulerView, subAgentView, selectedTopic]);
return ( return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden"> <div className="flex h-screen overflow-hidden bg-[var(--bg-primary)] text-[var(--text-primary)]">
{/* Header */} {/* 左栏 — harness 风格 shellLogo/连接/通道/会话/列表/底部操作区) */}
<header className="flex items-center justify-between border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md px-6 py-4"> <aside
<div className="flex items-center gap-4"> className={`relative shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col overflow-hidden ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}
<div className="flex items-center gap-2"> style={{
<Zap className="h-6 w-6 text-[var(--accent-cyan)]" /> width: sidebarCollapsed ? 56 : sidebarWidth,
<h1 className="text-xl font-bold tracking-tight"> transition: isResizing ? 'none' : 'width 200ms ease-out',
willChange: 'width',
}}
>
{/* Logo 行 */}
<div className="flex h-12 shrink-0 items-center gap-2 px-3">
{sidebarCollapsed ? (
<button
onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
aria-label="Expand sidebar"
>
<Zap className="h-5 w-5 text-[var(--accent-cyan)]" />
</button>
) : (
<>
<Zap className="h-5 w-5 shrink-0 text-[var(--accent-cyan)]" />
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold tracking-tight">
<span className="text-[var(--text-primary)]">Pico</span> <span className="text-[var(--text-primary)]">Pico</span>
<span className="text-[var(--accent-cyan)] glow-text">Bot</span> <span className="text-[var(--accent-cyan)]">Bot</span>
</h1> </h1>
{appVersion && ( {appVersion && (
<span className="text-xs font-mono text-[var(--text-muted)] select-none"> <span className="shrink-0 text-xs text-[var(--text-muted)] select-none">
v{appVersion} v{appVersion}
</span> </span>
)} )}
</div>
<div className="h-4 w-px bg-[var(--divider-color)]" />
<ConnectionStatus status={status} />
{/* 主题切换按钮 */}
<div className="h-4 w-px bg-[var(--divider-color)]" />
<button <button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))} onClick={toggleSidebar}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] transition-colors"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'} title="收起侧栏"
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} aria-label="Collapse sidebar"
> >
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} <PanelLeftClose className="h-4 w-4" />
</button>
</>
)}
</div>
{sidebarCollapsed ? (
/* 折叠 rail */
<div className="flex flex-1 flex-col items-center gap-1 px-1 pt-1">
<button
onClick={() => {
setSidebarTab('topics');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] bg-[var(--overlay-subtle)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`}
title="话题"
>
<MessageSquare className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => setShowThinking((prev) => !prev)} onClick={() => {
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${ setSidebarTab('scheduler');
showThinking setSidebarCollapsed(false);
? 'text-purple-400 hover:text-purple-300 bg-purple-500/10 hover:bg-purple-500/20' localStorage.setItem('picobot-sidebar-collapsed', 'false');
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]' }}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] bg-[var(--overlay-subtle)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`} }`}
title={showThinking ? '隐藏思考过程' : '显示思考过程'} title="定时任务"
aria-label="Toggle thinking display"
> >
<Brain className="h-4 w-4" /> <Calendar className="h-4 w-4" />
</button>
<div className="flex-1" />
<button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button> </button>
<button <button
onClick={() => { onClick={() => {
setConfigInitialTab('providers'); setConfigInitialTab('providers');
setConfigPageOpen(true); setConfigPageOpen(true);
}} }}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all" className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="系统配置" title="系统配置"
aria-label="System config"
> >
<SettingsIcon className="h-4 w-4" /> <SettingsIcon className="h-4 w-4" />
</button> </button>
<div className="pb-3 pt-2">
<ConnectionStatus status={status} compact />
</div> </div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]"> </div>
) : (
<>
<div className="shrink-0 px-3 pb-2">
<ConnectionStatus status={status} />
</div>
<div className="flex shrink-0 flex-col gap-1.5 px-3 pb-2 text-sm text-[var(--text-secondary)]">
<ChannelSelector <ChannelSelector
channels={channels} channels={channels}
selectedChannel={selectedChannel} selectedChannel={selectedChannel}
@ -679,101 +803,33 @@ function App() {
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
/> />
</div> </div>
</header>
{/* Main Content */} {/* Tab 栏 */}
<div className="flex flex-1 overflow-hidden relative"> <div className="flex shrink-0 border-b border-[var(--border-color)] px-3">
{/* Left Sidebar — smooth width animation via will-change + overflow-hidden */}
<div
className={`shrink-0 border-r border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${sidebarCollapsed ? 'w-11' : 'w-72'} ${subAgentView || schedulerView ? 'opacity-50 pointer-events-none' : ''}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
{/* Tab 栏 + collapse toggle */}
<div
className="flex border-b border-[var(--border-color)]"
style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}
>
{sidebarCollapsed ? (
/* 收起态:紧凑竖排 tab 按钮 */
<div className="flex flex-col w-full">
<button
onClick={() => {
setSidebarTab('topics');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="话题"
>
<br />
</button>
<button
onClick={() => {
setSidebarTab('scheduler');
setSidebarCollapsed(false);
localStorage.setItem('picobot-sidebar-collapsed', 'false');
}}
className={`py-2 text-xs font-medium text-center transition-colors ${
sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`}
title="定时任务"
>
<br />
</button>
{/* 展开按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center py-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
>
<PanelLeftOpen className="h-3.5 w-3.5" />
</button>
</div>
) : (
<>
<button <button
onClick={() => setSidebarTab('topics')} onClick={() => setSidebarTab('topics')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors whitespace-nowrap ${ className={`flex-1 border-b-2 py-2 text-center text-sm transition-colors whitespace-nowrap ${
sidebarTab === 'topics' sidebarTab === 'topics'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]' ? 'border-[var(--accent-cyan)] font-medium text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
</button> </button>
<button <button
onClick={() => setSidebarTab('scheduler')} onClick={() => setSidebarTab('scheduler')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors whitespace-nowrap ${ className={`flex-1 border-b-2 py-2 text-center text-sm transition-colors whitespace-nowrap ${
sidebarTab === 'scheduler' sidebarTab === 'scheduler'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]' ? 'border-[var(--accent-cyan)] font-medium text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`} }`}
> >
</button> </button>
{/* 收起按钮 */}
<button
onClick={toggleSidebar}
className="flex items-center justify-center px-2 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors shrink-0"
title="收起侧栏"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</>
)}
</div> </div>
<div <div className="flex-1 overflow-hidden">
className="flex-1 overflow-hidden" {sidebarTab === 'topics' ? (
style={{ minWidth: sidebarCollapsed ? 0 : '288px' }}
>
{sidebarCollapsed ? null : sidebarTab === 'topics' ? (
<TopicList <TopicList
sessionId={sessionId} sessionId={sessionId}
topics={topics} topics={topics}
@ -794,10 +850,58 @@ function App() {
/> />
)} )}
</div> </div>
</div>
{/* Center - Chat */} {/* 底部操作区:思考 / 主题 / 配置 */}
<div className="flex-1 min-w-0 bg-[var(--bg-primary)] flex flex-col"> <div className="flex shrink-0 items-center gap-1 border-t border-[var(--border-color)] px-3 py-2">
<button
onClick={() => setShowThinking((prev) => !prev)}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
showThinking
? 'text-[var(--accent-purple)] bg-[var(--accent-purple)]/10'
: 'text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)]'
}`}
title={showThinking ? '隐藏思考过程' : '显示思考过程'}
aria-label="Toggle thinking display"
>
<Brain className="h-4 w-4" />
</button>
<button
onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title={theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<div className="flex-1" />
<button
onClick={() => {
setConfigInitialTab('providers');
setConfigPageOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="系统配置"
aria-label="System config"
>
<SettingsIcon className="h-4 w-4" />
</button>
</div>
</>
)}
</aside>
{/* 左栏拖拽调宽手柄 */}
{!sidebarCollapsed && (
<div
onMouseDown={startSidebarDrag}
className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60"
role="separator"
aria-orientation="vertical"
/>
)}
{/* 中栏 — 对话区 */}
<div className="flex min-w-0 flex-1 flex-col bg-[var(--bg-primary)]">
{/* Scheduler job view back bar */} {/* Scheduler job view back bar */}
{schedulerView && ( {schedulerView && (
<div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4"> <div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4">
@ -810,7 +914,7 @@ function App() {
</button> </button>
<div className="h-4 w-px bg-[var(--divider-color)]" /> <div className="h-4 w-px bg-[var(--divider-color)]" />
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]"> <div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]">
<Clock className="h-4 w-4 text-amber-400" /> <Clock className="h-4 w-4 text-[var(--accent-amber)]" />
<span className="text-[var(--text-muted)]">:</span> <span className="text-[var(--text-muted)]">:</span>
<span className="text-[var(--text-primary)] font-medium font-mono text-xs truncate max-w-[250px]"> <span className="text-[var(--text-primary)] font-medium font-mono text-xs truncate max-w-[250px]">
{schedulerView.description} {schedulerView.description}
@ -861,13 +965,13 @@ function App() {
: level.status; : level.status;
const statusColor = const statusColor =
level.status === 'completed' level.status === 'completed'
? 'text-emerald-400' ? 'text-[var(--accent-green)]'
: level.status === 'failed' : level.status === 'failed'
? 'text-red-400' ? 'text-[rgb(242,90,90)]'
: level.status === 'timeout' : level.status === 'timeout'
? 'text-amber-400' ? 'text-[var(--accent-amber)]'
: level.status === 'running' : level.status === 'running'
? 'text-amber-400' ? 'text-[var(--accent-amber)]'
: 'text-[var(--text-secondary)]'; : 'text-[var(--text-secondary)]';
return ( return (
<div key={level.taskId} className="flex items-center gap-2 min-w-0"> <div key={level.taskId} className="flex items-center gap-2 min-w-0">
@ -935,10 +1039,24 @@ function App() {
</div> </div>
</div> </div>
{/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */} {/* 右栏拖拽调宽手柄 */}
{!rightSidebarCollapsed && (
<div <div
className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`} onMouseDown={startRightPanelDrag}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }} className="w-1.5 shrink-0 cursor-col-resize transition-colors hover:bg-[var(--accent-cyan)]/40 active:bg-[var(--accent-cyan)]/60"
role="separator"
aria-orientation="vertical"
/>
)}
{/* 右栏 — Token 统计 / 待办 / 记忆 / 技能(可折叠) */}
<div
className="relative shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col overflow-hidden"
style={{
width: rightSidebarCollapsed ? 44 : rightPanelWidth,
transition: isResizing ? 'none' : 'width 200ms ease-out',
willChange: 'width',
}}
> >
{rightSidebarCollapsed ? ( {rightSidebarCollapsed ? (
// 折叠态:窄条 + 展开按钮 // 折叠态:窄条 + 展开按钮
@ -950,7 +1068,7 @@ function App() {
<PanelLeftOpen className="h-4 w-4 rotate-180" /> <PanelLeftOpen className="h-4 w-4 rotate-180" />
</button> </button>
) : ( ) : (
<div className="w-80 h-full flex flex-col"> <div className="flex h-full min-w-0 flex-col">
{/* Token 用量面板(主代理/子代理共用,按视图切换数据源) */} {/* Token 用量面板(主代理/子代理共用,按视图切换数据源) */}
<TopicTokenStatsPanel <TopicTokenStatsPanel
tokenStats={subAgentView ? subAgentView.tokenStats : currentTopic?.tokenStats} tokenStats={subAgentView ? subAgentView.tokenStats : currentTopic?.tokenStats}
@ -1020,7 +1138,6 @@ function App() {
</div> </div>
)} )}
</div> </div>
</div>
{/* 系统配置页面 */} {/* 系统配置页面 */}
{configPageOpen && ( {configPageOpen && (

View File

@ -3,6 +3,7 @@ import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput'; import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector'; import { ExpertSelector } from './ExpertSelector';
import { ModelSelector } from './ModelSelector'; import { ModelSelector } from './ModelSelector';
import { Zap } from 'lucide-react';
import type { ChatMessage, Attachment } from '../../types/protocol'; import type { ChatMessage, Attachment } from '../../types/protocol';
interface ChatContainerProps { interface ChatContainerProps {
@ -49,18 +50,8 @@ export function ChatContainer({
description: string; description: string;
} | null>(null); } | null>(null);
return ( const selectors = (
<div className="flex h-full flex-col relative"> <div className="flex flex-wrap items-center gap-1 px-3 pt-2">
<div className="flex-1 overflow-hidden relative">
<MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
/>
</div>
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
<ExpertSelector <ExpertSelector
sessionId={sessionId ?? null} sessionId={sessionId ?? null}
onManageExperts={onOpenSettings} onManageExperts={onOpenSettings}
@ -69,6 +60,9 @@ export function ChatContainer({
/> />
<ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} /> <ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} />
</div> </div>
);
const input = (
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}
onStop={onStop} onStop={onStop}
@ -79,6 +73,49 @@ export function ChatContainer({
selectedExpert={selectedExpert} selectedExpert={selectedExpert}
topicId={topicId} topicId={topicId}
/> />
);
/* Hero + +
composer remount
稿 */
const isHero = messages.length === 0 && !isLoading && !isReadOnly;
return (
<div className="relative flex h-full w-full flex-col">
{isHero && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(closest-side at 50% 42%, color-mix(in srgb, var(--accent-cyan) 9%, transparent), transparent)',
}}
/>
)}
{isHero ? (
<div className="relative flex flex-1 flex-col items-center justify-center px-6 pb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
<Zap className="h-6 w-6 text-[var(--accent-cyan)]" />
</div>
<h1 className="mt-4 text-[26px] font-semibold text-[var(--text-primary)]">PicoBot</h1>
<p className="mt-1 text-sm text-[var(--text-muted)]"></p>
</div>
) : (
<div className="relative flex-1 overflow-hidden">
<MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
/>
</div>
)}
<div className="relative shrink-0 px-6 pb-4">
<div className={`mx-auto w-full ${isHero ? 'max-w-[680px]' : 'max-w-[736px]'}`}>
{selectors}
{input}
</div>
</div>
</div> </div>
); );
} }

View File

@ -256,7 +256,7 @@ export function ExpertSelector({
</div> </div>
)} )}
</div> </div>
{error && <span className="text-xs text-red-400 truncate">{error}</span>} {error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div> </div>
); );
} }

View File

@ -1,28 +1,23 @@
import { useState, useEffect, memo } from 'react'; import { useState, useEffect, memo } from 'react';
import { import {
User,
Bot,
Wrench,
CheckCircle, CheckCircle,
AlertCircle,
Terminal,
File,
Image,
FileText,
Music,
Video,
Download,
ChevronDown,
ChevronRight, ChevronRight,
Copy, ChevronDown,
Check,
Loader2,
XCircle,
Clock, Clock,
Loader, Loader,
X, Loader2,
XCircle,
Copy,
Check,
Brain, Brain,
Maximize2, Maximize2,
Download,
File,
FileText,
Image,
Music,
Video,
X,
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -289,7 +284,7 @@ function CopyButton({ text, className = '' }: { text: string; className?: string
title="复制" title="复制"
> >
{copied ? ( {copied ? (
<Check className="h-3 w-3 text-emerald-400" /> <Check className="h-3 w-3 text-[var(--accent-green)]" />
) : ( ) : (
<Copy className="h-3 w-3 text-[var(--text-muted)]" /> <Copy className="h-3 w-3 text-[var(--text-muted)]" />
)} )}
@ -385,25 +380,25 @@ export const MessageBubble = memo(function MessageBubble({
const statusConfig = { const statusConfig = {
calling: { calling: {
dot: 'bg-amber-400 animate-pulse', dot: 'bg-[var(--accent-cyan)] animate-pulse',
fullBorder: 'border-amber-500/30', fullBorder: 'border-[var(--accent-cyan)]/30',
iconColor: 'text-amber-400', iconColor: 'text-[var(--accent-cyan)]',
avatarBg: 'bg-amber-500/20', avatarBg: 'bg-[var(--accent-cyan)]/10',
avatarIcon: 'text-amber-400', avatarIcon: 'text-[var(--accent-cyan)]',
}, },
result: { result: {
dot: 'bg-emerald-400', dot: 'bg-[var(--accent-green)]',
fullBorder: 'border-emerald-500/30', fullBorder: 'border-[var(--accent-green)]/30',
iconColor: 'text-emerald-400', iconColor: 'text-[var(--accent-green)]',
avatarBg: 'bg-emerald-500/20', avatarBg: 'bg-[var(--accent-green)]/10',
avatarIcon: 'text-emerald-400', avatarIcon: 'text-[var(--accent-green)]',
}, },
pending: { pending: {
dot: 'bg-orange-400 animate-pulse', dot: 'bg-[var(--accent-amber)] animate-pulse',
fullBorder: 'border-orange-500/30', fullBorder: 'border-[var(--accent-amber)]/30',
iconColor: 'text-orange-400', iconColor: 'text-[var(--accent-amber)]',
avatarBg: 'bg-orange-500/20', avatarBg: 'bg-[var(--accent-amber)]/10',
avatarIcon: 'text-orange-400', avatarIcon: 'text-[var(--accent-amber)]',
}, },
}[status]; }[status];
@ -450,35 +445,39 @@ export const MessageBubble = memo(function MessageBubble({
// - interrupted: 子代理因服务器重启被中断 // - interrupted: 子代理因服务器重启被中断
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = { const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
running: { running: {
dot: 'bg-amber-400 animate-pulse', dot: 'bg-[var(--accent-amber)] animate-pulse',
borderColor: 'border-amber-500/40', borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-amber-400', iconColor: 'text-[var(--accent-amber)]',
}, },
success: { success: {
dot: 'bg-emerald-400', dot: 'bg-[var(--accent-green)]',
borderColor: 'border-emerald-500/40', borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-emerald-400', iconColor: 'text-[var(--accent-green)]',
}, },
completed: { completed: {
dot: 'bg-emerald-400', dot: 'bg-[var(--accent-green)]',
borderColor: 'border-emerald-500/40', borderColor: 'border-[var(--accent-green)]/40',
iconColor: 'text-emerald-400', iconColor: 'text-[var(--accent-green)]',
},
failed: {
dot: 'bg-[rgb(242,90,90)]',
borderColor: 'border-[rgb(242,90,90)]/40',
iconColor: 'text-[rgb(242,90,90)]',
}, },
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
timeout: { timeout: {
dot: 'bg-amber-400', dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-amber-500/40', borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-amber-400', iconColor: 'text-[var(--accent-amber)]',
}, },
cancelled: { cancelled: {
dot: 'bg-zinc-400', dot: 'bg-[var(--text-muted)]',
borderColor: 'border-zinc-500/40', borderColor: 'border-[var(--border-color)]',
iconColor: 'text-zinc-400', iconColor: 'text-[var(--text-muted)]',
}, },
interrupted: { interrupted: {
dot: 'bg-orange-400', dot: 'bg-[var(--accent-amber)]',
borderColor: 'border-orange-500/40', borderColor: 'border-[var(--accent-amber)]/40',
iconColor: 'text-orange-400', iconColor: 'text-[var(--accent-amber)]',
}, },
}; };
@ -486,35 +485,10 @@ export const MessageBubble = memo(function MessageBubble({
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null; const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
return ( return (
<div data-message-id={message.id} className="flex gap-3 animate-slide-in"> <div data-message-id={message.id} className="animate-slide-in">
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${
isTaskTool ? 'bg-violet-500/20' : statusConfig.avatarBg
}`}
>
{isTaskTool ? (
<Bot className="h-3.5 w-3.5 text-violet-400" />
) : (
<Terminal className={`h-3.5 w-3.5 ${statusConfig.avatarIcon}`} />
)}
</div>
<div className="max-w-[80%] min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-[var(--text-secondary)]">
{message.toolName || 'Tool'}
</span>
{isTaskTool && (
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
·{subagentType}
</span>
)}
<span className="text-xs text-[var(--text-muted)]">
{formatTime(message.timestamp)}
</span>
</div>
<div <div
onClick={() => setToolExpanded(!toolExpanded)} onClick={() => setToolExpanded(!toolExpanded)}
className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${ className={`group w-full cursor-pointer rounded-xl border bg-[var(--bg-secondary)]/60 transition-colors duration-300 hover:bg-[var(--bg-secondary)] ${
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
}`} }`}
> >
@ -660,10 +634,10 @@ export const MessageBubble = memo(function MessageBubble({
<div <div
className={`rounded-lg px-3 py-2 ${ className={`rounded-lg px-3 py-2 ${
taskResult.status === 'success' taskResult.status === 'success'
? 'bg-emerald-500/10 border border-emerald-500/30' ? 'bg-[var(--accent-green)]/10 border border-[var(--accent-green)]/30'
: taskResult.status === 'failed' : taskResult.status === 'failed'
? 'bg-red-500/10 border border-red-500/30' ? 'bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/30'
: 'bg-amber-500/10 border border-amber-500/30' : 'bg-[var(--accent-amber)]/10 border border-[var(--accent-amber)]/30'
}`} }`}
> >
<div className="text-xs font-medium text-[var(--text-muted)] mb-0.5"> <div className="text-xs font-medium text-[var(--text-muted)] mb-0.5">
@ -743,6 +717,14 @@ export const MessageBubble = memo(function MessageBubble({
</div> </div>
)} )}
</div> </div>
<div className="mt-1 flex items-center gap-2 px-1">
<span className="text-xs text-[var(--text-muted)]">{message.toolName || 'Tool'}</span>
{isTaskTool && (
<span className="rounded bg-[var(--accent-purple)]/10 px-1.5 py-0.5 text-xs text-[var(--accent-purple)]">
·{subagentType}
</span>
)}
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
</div> </div>
{lightboxElement} {lightboxElement}
{showDetailModal && ( {showDetailModal && (
@ -776,75 +758,38 @@ export const MessageBubble = memo(function MessageBubble({
return null; return null;
} }
const getIcon = () => {
if (isUser) return <User className="h-4 w-4" />;
if (isTool) {
if (message.type === 'tool_call') return <Terminal className="h-4 w-4" />;
if (message.type === 'tool_result') return <CheckCircle className="h-4 w-4" />;
if (message.type === 'tool_pending') return <AlertCircle className="h-4 w-4" />;
return <Wrench className="h-4 w-4" />;
}
return <Bot className="h-4 w-4" />;
};
const getContainerStyles = () => {
if (isUser) {
return 'bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 border-[var(--accent-cyan)]/30 text-[var(--text-primary)]';
}
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500/10 border-amber-500/30 text-amber-100';
if (message.type === 'tool_result')
return 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100';
if (message.type === 'tool_pending')
return 'bg-orange-500/10 border-orange-500/30 text-orange-100';
return 'bg-[var(--bg-hover)] border-[var(--border-color)] text-[var(--text-secondary)]';
}
return 'bg-[var(--bg-tertiary)] border-[var(--border-color)] text-[var(--text-primary)]';
};
const getAvatarStyles = () => {
if (isUser) return 'bg-gradient-to-br from-[var(--accent-cyan)] to-[var(--accent-blue)]';
if (isTool) {
if (message.type === 'tool_call') return 'bg-amber-500';
if (message.type === 'tool_result') return 'bg-emerald-500';
if (message.type === 'tool_pending') return 'bg-orange-500';
return 'bg-zinc-700';
}
return 'bg-gradient-to-br from-[var(--accent-purple)] to-[#ec4899]';
};
return ( return (
<div <div
data-message-id={message.id} data-message-id={message.id}
className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`} className={`animate-slide-in group ${isUser ? 'flex flex-col items-end' : ''}`}
> >
<div {!isUser && (
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`} <div className="mb-1 flex items-center gap-2">
> {isTool && message.toolName ? (
{getIcon()} <span className="text-xs font-medium text-[var(--text-secondary)]">
</div> {message.toolName}
<div className={`max-w-[80%] ${isUser ? 'text-right' : 'text-left'}`}> </span>
<div className="flex items-center gap-2 mb-1"> ) : null}
<span <span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
className={`text-xs font-medium ${isUser ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'}`} {formatTime(message.timestamp)}
>
{isUser ? 'You' : isTool ? message.toolName || 'Tool' : 'Assistant'}
</span> </span>
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
<CopyButton text={message.content} /> <CopyButton text={message.content} />
</div> </div>
<div
className={`inline-block rounded-2xl border px-5 py-3 text-left shadow-lg ${getContainerStyles()}`}
>
{isTool && message.toolName && (
<div className="mb-2 text-xs font-semibold opacity-70 flex items-center gap-1">
<Terminal className="h-3 w-3" />
{message.toolName}
</div>
)} )}
<div
className={
isUser
? 'max-w-[min(525px,82%)] rounded-[22px] bg-[var(--bubble-user)] px-4 py-2.5 text-left'
: 'w-full min-w-0'
}
>
{isUser ? ( {isUser ? (
// 用户消息保持纯文本 // 用户消息保持纯文本(右对齐柔和气泡)
<div className="whitespace-pre-wrap text-sm leading-relaxed">{message.content}</div> <div className="whitespace-pre-wrap text-[15px] leading-6 text-[var(--text-primary)]">
{message.content}
</div>
) : ( ) : (
// 模型思考内容(仅助手消息,非工具消息) // 模型思考内容(仅助手消息,非工具消息)
<> <>
@ -855,7 +800,7 @@ export const MessageBubble = memo(function MessageBubble({
)} )}
{/* AI 和工具消息使用 Markdown 渲染 */} {/* AI 和工具消息使用 Markdown 渲染 */}
{message.content.trim() && ( {message.content.trim() && (
<div className="markdown-content text-sm leading-relaxed"> <div className="markdown-content text-[15px] leading-6">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
@ -992,7 +937,14 @@ export const MessageBubble = memo(function MessageBubble({
</div> </div>
)} )}
</div> </div>
{isUser && (
<div className="mt-1 flex items-center gap-2 px-1">
<span className="text-xs text-[var(--text-muted)] opacity-0 transition-opacity group-hover:opacity-100">
{formatTime(message.timestamp)}
</span>
<CopyButton text={message.content} />
</div> </div>
)}
{lightboxElement} {lightboxElement}
</div> </div>
); );

View File

@ -2,7 +2,6 @@ import {
Send, Send,
Loader2, Loader2,
Square, Square,
Sparkles,
Eye, Eye,
Paperclip, Paperclip,
X, X,
@ -284,9 +283,8 @@ export function MessageInput({
// 只读模式:显示提示占位符 // 只读模式:显示提示占位符
if (isReadOnly) { if (isReadOnly) {
return ( return (
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4"> <div className="pt-2">
<div className="max-w-5xl mx-auto"> <div className="rounded-2xl border border-[var(--border-color)] bg-[var(--surface-input)] px-4 py-5 text-center shadow-sm">
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/50 px-4 py-5 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2 text-[var(--text-secondary)]"> <div className="flex items-center gap-2 text-[var(--text-secondary)]">
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
@ -303,16 +301,14 @@ export function MessageInput({
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
return ( return (
<div className="border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md p-4"> <div className="pt-2">
<div className="max-w-5xl mx-auto">
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && (
<div className="mb-2 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm text-red-400"> <div className="mb-2 rounded-lg bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/25 px-3 py-2 text-sm text-[rgb(242,90,90)]">
{error} {error}
</div> </div>
)} )}
@ -341,7 +337,7 @@ export function MessageInput({
</span> </span>
<button <button
onClick={() => handleRemoveAttachment(index)} onClick={() => handleRemoveAttachment(index)}
className="text-[var(--text-muted)] hover:text-red-400 transition-colors" className="text-[var(--text-muted)] hover:text-[rgb(242,90,90)] transition-colors"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@ -350,9 +346,13 @@ export function MessageInput({
</div> </div>
)} )}
{/* 输入区域 */} {/* Composer 卡片 */}
<div <div
className={`flex gap-3 items-center relative ${isDragging ? 'ring-2 ring-[var(--accent-cyan)]/50 rounded-xl' : ''}`} className={`relative rounded-2xl border bg-[var(--surface-input)] shadow-sm transition-colors ${
isDragging
? 'border-[var(--border-accent)]'
: 'border-[var(--border-color)] focus-within:border-[var(--border-accent)]'
}`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@ -360,29 +360,11 @@ export function MessageInput({
> >
{/* 拖拽提示 */} {/* 拖拽提示 */}
{isDragging && ( {isDragging && (
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10"> <div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border border-[var(--border-accent)] bg-[var(--accent-cyan)]/10">
<div className="text-[var(--accent-cyan)] text-sm font-medium"></div> <div className="text-sm font-medium text-[var(--accent-cyan)]"></div>
</div> </div>
)} )}
{/* 附件按钮 */}
<button
onClick={handleAttachClick}
disabled={disabled}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-color)] disabled:opacity-50 transition-all"
>
<Paperclip className="h-5 w-5" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
{/* 输入框 */}
<div className="flex-1 relative flex items-center">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={content} value={content}
@ -392,40 +374,58 @@ export function MessageInput({
placeholder={effectivePlaceholder} placeholder={effectivePlaceholder}
disabled={disabled} disabled={disabled}
rows={1} rows={1}
className="w-full resize-none rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-4 py-3 pr-12 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-cyan)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--focus-ring)] disabled:opacity-50 transition-all self-center scrollbar-hide" className="composer-area w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[15px] leading-6 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] disabled:opacity-50 scrollbar-hide"
/> />
<Sparkles className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--text-muted)] pointer-events-none" />
</div>
{/* 发送/停止按钮 */} {/* 底部操作行 */}
<div className="flex items-center gap-1 px-2 pb-2">
<button
onClick={handleAttachClick}
disabled={disabled}
title="添加附件"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--text-primary)] disabled:opacity-50 transition-colors"
>
<Paperclip className="h-4 w-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<div className="flex-1" />
{isLoading && onStop ? ( {isLoading && onStop ? (
<button <button
onClick={onStop} onClick={onStop}
title="停止执行" title="停止执行"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-all" aria-label="Stop generating"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--border-color)] text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[rgb(242,90,90)] transition-colors"
> >
<Square className="h-4 w-4" /> <Square className="h-3.5 w-3.5" />
</button> </button>
) : ( ) : (
<button <button
onClick={handleSend} onClick={handleSend}
disabled={disabled || (!content.trim() && attachments.length === 0)} disabled={disabled || (!content.trim() && attachments.length === 0)}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-[var(--accent-cyan)] to-[var(--accent-blue)] text-white shadow-lg shadow-[var(--accent-cyan)]/20 hover:shadow-xl hover:shadow-[var(--accent-cyan)]/30 hover:scale-105 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed transition-all" title="发送"
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--btn-contrast)] text-[var(--btn-contrast-fg)] hover:opacity-85 disabled:opacity-35 disabled:cursor-not-allowed transition-opacity"
> >
{disabled ? ( {disabled ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Send className="h-4 w-4" /> <Send className="h-3.5 w-3.5" />
)} )}
</button> </button>
)} )}
</div> </div>
</div>
{/* 提示 */} {/* 提示 */}
<div className="mt-2 text-center text-xs text-[var(--text-muted)]"> <div className="mt-2 text-center text-xs text-[var(--text-muted)]">
Enter Shift+Enter · / · 50MB Enter Shift+Enter · / · 50MB
</div> </div>
</div> </div>
</div>
); );
} }

View File

@ -182,25 +182,57 @@ export function MessageList({
}); });
}, [highlightedMessageId, virtualizer]); }, [highlightedMessageId, virtualizer]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
// 滚动停止后或对话列宽度变化(拖拽调宽/折叠侧栏)时,换行行可能保留 120px 估值导致行重叠。
// 在这两个时机绕过门控,直接对已渲染行调用 resizeItem 强制纠正。
const remeasureRows = useCallback(() => {
const container = containerRef.current;
if (!container) return;
for (const vi of virtualizer.getVirtualItems()) {
const node = container.querySelector<HTMLElement>(`[data-index="${vi.index}"]`);
if (node) {
const h = Math.round(node.getBoundingClientRect().height);
if (h > 0) virtualizer.resizeItem(vi.index, h);
}
}
}, [virtualizer]);
// 宽度变化后强制重测(含一次 rAF 补测同提交内的重 stamped 行)
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') return;
let lastWidth = container.clientWidth;
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
if (w === lastWidth) return;
lastWidth = w;
remeasureRows();
requestAnimationFrame(remeasureRows);
});
ro.observe(container);
return () => ro.disconnect();
}, [virtualizer, remeasureRows]);
// 滚动停止后补测一次(滚动期间被门控跳过的行在此纠正)
const isScrolling = virtualizer.isScrolling;
useEffect(() => {
if (isScrolling) return;
const id = requestAnimationFrame(remeasureRows);
return () => cancelAnimationFrame(id);
}, [isScrolling, remeasureRows]);
// ---- empty state ---- // ---- empty state ----
if (messages.length === 0) { if (messages.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center animate-fade-in"> <div className="text-center animate-fade-in">
<div className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 shadow-2xl shadow-[var(--accent-cyan)]/20"> <div className="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
<Sparkles className="h-10 w-10 text-[var(--accent-cyan)]" /> <Sparkles className="h-8 w-8 text-[var(--accent-cyan)]" />
</div>
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2>
<p className="text-[var(--text-muted)]"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
/new
</span>
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
/list
</span>
</div> </div>
<h2 className="mb-2 text-xl font-semibold text-[var(--text-primary)]"></h2>
<p className="text-sm text-[var(--text-muted)]"> AI </p>
</div> </div>
</div> </div>
); );
@ -237,12 +269,14 @@ export function MessageList({
transform: `translateY(${vi.start}px)`, transform: `translateY(${vi.start}px)`,
}} }}
> >
<div className="mx-auto w-full max-w-[736px]">
<MessageBubble <MessageBubble
message={message} message={message}
onNavigateToSubAgent={onNavigateToSubAgent} onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking} showThinking={showThinking}
/> />
</div> </div>
</div>
); );
})} })}
</div> </div>
@ -256,11 +290,9 @@ export function MessageList({
onClick={scrollToTop} onClick={scrollToTop}
className="flex items-center gap-1.5 px-3 py-2 rounded-full className="flex items-center gap-1.5 px-3 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)] border border-[var(--border-color)] shadow-sm
text-[var(--text-muted)] hover:text-[var(--accent-cyan)] text-[var(--text-muted)] hover:text-[var(--text-primary)]
hover:border-[var(--accent-cyan)]/30 transition-colors duration-200 ease-out
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in" animate-fade-in"
aria-label="回到顶部" aria-label="回到顶部"
> >
@ -273,11 +305,9 @@ export function MessageList({
onClick={() => scrollToBottom('smooth')} onClick={() => scrollToBottom('smooth')}
className="flex items-center gap-2 px-4 py-2 rounded-full className="flex items-center gap-2 px-4 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)] border border-[var(--border-color)] shadow-sm
text-[var(--text-muted)] hover:text-[var(--accent-cyan)] text-[var(--text-muted)] hover:text-[var(--text-primary)]
hover:border-[var(--accent-cyan)]/30 transition-colors duration-200 ease-out
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in" animate-fade-in"
aria-label="回到底部" aria-label="回到底部"
> >

View File

@ -229,7 +229,7 @@ export function ModelSelector({
</label> </label>
</div> </div>
{error && <div className="text-xs text-red-400 truncate">{error}</div>} {error && <div className="text-xs text-[rgb(242,90,90)] truncate">{error}</div>}
<div className="flex items-center justify-between gap-2 pt-1"> <div className="flex items-center justify-between gap-2 pt-1">
<button <button
@ -260,7 +260,7 @@ export function ModelSelector({
</div> </div>
)} )}
</div> </div>
{error && <span className="text-xs text-red-400 truncate">{error}</span>} {error && <span className="text-xs text-[rgb(242,90,90)] truncate">{error}</span>}
</div> </div>
); );
} }

View File

@ -59,7 +59,7 @@ export function ToolDetailModal({
: status === 'result' : status === 'result'
? 'var(--accent-green)' ? 'var(--accent-green)'
: status === 'pending' : status === 'pending'
? '#f59e0b' ? 'var(--accent-amber)'
: 'var(--text-muted)'; : 'var(--text-muted)';
return ( return (
@ -81,9 +81,9 @@ export function ToolDetailModal({
<span <span
className="px-2.5 py-1 rounded-full text-sm font-medium" className="px-2.5 py-1 rounded-full text-sm font-medium"
style={{ style={{
backgroundColor: `${statusColor}15`, backgroundColor: `color-mix(in srgb, ${statusColor} 12%, transparent)`,
color: statusColor, color: statusColor,
border: `1px solid ${statusColor}30`, border: `1px solid color-mix(in srgb, ${statusColor} 25%, transparent)`,
}} }}
> >
{statusLabel} {statusLabel}

View File

@ -3,34 +3,40 @@ import type { ConnectionStatus } from '../types/protocol';
interface ConnectionStatusProps { interface ConnectionStatusProps {
status: ConnectionStatus; status: ConnectionStatus;
/** rail 紧凑模式:仅显示图标 */
compact?: boolean;
} }
export function ConnectionStatus({ status }: ConnectionStatusProps) { export function ConnectionStatus({ status, compact = false }: ConnectionStatusProps) {
const getStatusConfig = () => { const getStatusConfig = () => {
switch (status) { switch (status) {
case 'connecting': case 'connecting':
return { return {
icon: <Loader2 className="h-3 w-3 animate-spin" />, icon: <Loader2 className={`animate-spin ${compact ? 'h-4 w-4' : 'h-3 w-3'}`} />,
text: '连接中', text: '连接中',
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30', className:
'text-[var(--accent-amber)] bg-[var(--accent-amber)]/10 border-[var(--accent-amber)]/30',
}; };
case 'connected': case 'connected':
return { return {
icon: <Wifi className="h-3 w-3" />, icon: <Wifi className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '已连接', text: '已连接',
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30', className:
'text-[var(--accent-green)] bg-[var(--accent-green)]/10 border-[var(--accent-green)]/30',
}; };
case 'disconnected': case 'disconnected':
return { return {
icon: <WifiOff className="h-3 w-3" />, icon: <WifiOff className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '已断开', text: '已断开',
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30', className:
'text-[var(--text-muted)] bg-[var(--overlay-dim)] border-[var(--border-color)]',
}; };
case 'error': case 'error':
return { return {
icon: <WifiOff className="h-3 w-3" />, icon: <WifiOff className={compact ? 'h-4 w-4' : 'h-3 w-3'} />,
text: '连接错误', text: '连接错误',
className: 'text-red-400 bg-red-400/10 border-red-400/30', className:
'text-[rgb(242,90,90)] bg-[rgb(242,90,90)]/10 border-[rgb(242,90,90)]/30',
}; };
} }
}; };
@ -39,9 +45,21 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) {
if (!config) return null; if (!config) return null;
if (compact) {
return ( return (
<div <div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`} className={`flex h-8 w-8 items-center justify-center rounded-lg border ${config.className}`}
title={config.text}
aria-label={config.text}
>
{config.icon}
</div>
);
}
return (
<div
className={`inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${config.className}`}
> >
{config.icon} {config.icon}
<span>{config.text}</span> <span>{config.text}</span>

View File

@ -110,11 +110,11 @@ export function ChannelSelector({
ref={triggerRef} ref={triggerRef}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={` className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between group flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm justify-between
transition-all duration-200 ease-out transition-colors duration-200 ease-out
${ ${
isOpen isOpen
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' ? 'border-[var(--border-accent)] bg-[var(--overlay-subtle)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]' : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
} }
`} `}
@ -128,7 +128,7 @@ export function ChannelSelector({
</span> </span>
{/* Channel name */} {/* Channel name */}
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center"> <span className="flex-1 min-w-0 text-[var(--text-primary)] text-[13px] truncate text-left">
{selected?.name || '选择通道'} {selected?.name || '选择通道'}
</span> </span>
@ -137,9 +137,7 @@ export function ChannelSelector({
{selected ? ( {selected ? (
<span <span
className={`h-1.5 w-1.5 rounded-full ${ className={`h-1.5 w-1.5 rounded-full ${
selected.isWritable selected.isWritable ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
: 'bg-zinc-500'
}`} }`}
/> />
) : ( ) : (
@ -203,7 +201,7 @@ export function ChannelSelector({
className={` className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200 transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'} ${isActive ? 'bg-[var(--accent-cyan)]' : 'bg-transparent'}
`} `}
/> />
@ -243,10 +241,9 @@ export function ChannelSelector({
transition-all duration-200 transition-all duration-200
${ ${
channel.isWritable channel.isWritable
? 'bg-emerald-400/10 text-emerald-400' ? 'bg-[var(--accent-green)]/10 text-[var(--accent-green)]'
: 'bg-zinc-500/10 text-zinc-500' : 'bg-[var(--overlay-dim)] text-[var(--text-muted)]'
} }
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
`} `}
> >
{channel.isWritable ? ( {channel.isWritable ? (

View File

@ -80,19 +80,19 @@ export function SessionSelector({
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
disabled={sessions.length <= 1} disabled={sessions.length <= 1}
className={` className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between group flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm justify-between
transition-all duration-200 ease-out transition-colors duration-200 ease-out
${ ${
sessions.length <= 1 sessions.length <= 1
? 'border-[var(--border-color)] cursor-default' ? 'border-[var(--border-color)] cursor-default'
: isOpen : isOpen
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' ? 'border-[var(--border-accent)] bg-[var(--overlay-subtle)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer' : 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
} }
`} `}
> >
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" /> <MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" />
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center"> <span className="flex-1 min-w-0 text-[var(--text-primary)] text-[13px] truncate text-left">
{selected?.title || '无会话'} {selected?.title || '无会话'}
</span> </span>
{sessions.length > 1 ? ( {sessions.length > 1 ? (
@ -151,7 +151,7 @@ export function SessionSelector({
className={` className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200 transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'} ${isActive ? 'bg-[var(--accent-cyan)]' : 'bg-transparent'}
`} `}
/> />
<MessageSquare <MessageSquare

View File

@ -42,44 +42,44 @@ const NS: Record<string, NamespaceConfig> = {
user: { user: {
label: '用户记忆', label: '用户记忆',
icon: User, icon: User,
accent: 'text-cyan-400', accent: 'text-[var(--accent-cyan)]',
accentBorder: 'border-cyan-400/40', accentBorder: 'border-[var(--accent-cyan)]/40',
}, },
semantic: { semantic: {
label: '语义记忆', label: '语义记忆',
icon: Library, icon: Library,
accent: 'text-amber-400', accent: 'text-[var(--accent-amber)]',
accentBorder: 'border-amber-400/40', accentBorder: 'border-[var(--accent-amber)]/40',
}, },
episodic: { episodic: {
label: '情景记忆', label: '情景记忆',
icon: History, icon: History,
accent: 'text-purple-400', accent: 'text-[var(--accent-purple)]',
accentBorder: 'border-purple-400/40', accentBorder: 'border-[var(--accent-purple)]/40',
}, },
skill: { skill: {
label: '技能记忆', label: '技能记忆',
icon: Cpu, icon: Cpu,
accent: 'text-green-400', accent: 'text-[var(--accent-green)]',
accentBorder: 'border-green-400/40', accentBorder: 'border-[var(--accent-green)]/40',
}, },
environment: { environment: {
label: '环境记忆', label: '环境记忆',
icon: Globe, icon: Globe,
accent: 'text-sky-400', accent: 'text-[var(--accent-blue)]',
accentBorder: 'border-sky-400/40', accentBorder: 'border-[var(--accent-blue)]/40',
}, },
reflection: { reflection: {
label: '反思记忆', label: '反思记忆',
icon: Star, icon: Star,
accent: 'text-rose-400', accent: 'text-[rgb(242,90,90)]',
accentBorder: 'border-rose-400/40', accentBorder: 'border-[rgb(242,90,90)]/40',
}, },
other: { other: {
label: '其他', label: '其他',
icon: Package, icon: Package,
accent: 'text-stone-400', accent: 'text-[var(--text-muted)]',
accentBorder: 'border-stone-400/40', accentBorder: 'border-[var(--border-color)]',
}, },
}; };
@ -159,7 +159,7 @@ function MemoryCard({
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`} className={`p-1 rounded hover:bg-[rgb(242,90,90)]/10 ${confirmDelete ? 'text-[rgb(242,90,90)]' : 'text-[var(--text-muted)]'} hover:text-[rgb(242,90,90)] transition-colors`}
title={confirmDelete ? '再次点击确认删除' : '删除'} title={confirmDelete ? '再次点击确认删除' : '删除'}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />

View File

@ -24,12 +24,12 @@ interface SourceConfig {
} }
const SOURCE_CONFIG: Record<string, SourceConfig> = { const SOURCE_CONFIG: Record<string, SourceConfig> = {
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' }, user: { label: '用户技能', icon: User, accent: 'text-[var(--accent-cyan)]' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' }, useragent: { label: '用户 Agent', icon: User, accent: 'text-[var(--accent-cyan)]' },
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' }, useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-[var(--accent-cyan)]' },
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' }, project: { label: '项目技能', icon: Folder, accent: 'text-[var(--accent-amber)]' },
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-amber-400' }, projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-[var(--accent-amber)]' },
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' }, projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-[var(--accent-amber)]' },
}; };
function sourceConfig(source: string): SourceConfig { function sourceConfig(source: string): SourceConfig {

View File

@ -18,9 +18,9 @@ interface StatusCfg {
} }
const STATUS: Record<string, StatusCfg> = { const STATUS: Record<string, StatusCfg> = {
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' }, in_progress: { label: '进行中', color: 'text-[var(--accent-amber)]', dot: 'bg-[var(--accent-amber)]' },
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' }, pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' }, completed: { label: '已完成', color: 'text-[var(--accent-green)]', dot: 'bg-[var(--accent-green)]' },
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' }, cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
}; };
@ -45,8 +45,8 @@ function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
function PulseDot() { function PulseDot() {
return ( return (
<span className="relative flex h-1.5 w-1.5 shrink-0"> <span className="relative flex h-1.5 w-1.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--accent-amber)] opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" /> <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[var(--accent-amber)]" />
</span> </span>
); );
} }

View File

@ -107,29 +107,29 @@ export function ToolPanel({ messages }: ToolPanelProps) {
case 'calling': case 'calling':
return { return {
icon: Play, icon: Play,
iconColor: 'text-amber-400', iconColor: 'text-[var(--accent-amber)]',
bgClass: 'bg-amber-400', bgClass: 'bg-[var(--accent-amber)]',
borderClass: 'border-amber-500/30', borderClass: 'border-[var(--accent-amber)]/30',
label: '执行中', label: '执行中',
labelClass: 'text-amber-400', labelClass: 'text-[var(--accent-amber)]',
}; };
case 'result': case 'result':
return { return {
icon: Check, icon: Check,
iconColor: 'text-emerald-400', iconColor: 'text-[var(--accent-green)]',
bgClass: 'bg-emerald-400', bgClass: 'bg-[var(--accent-green)]',
borderClass: 'border-emerald-500/30', borderClass: 'border-[var(--accent-green)]/30',
label: '已完成', label: '已完成',
labelClass: 'text-emerald-400', labelClass: 'text-[var(--accent-green)]',
}; };
case 'pending': case 'pending':
return { return {
icon: AlertTriangle, icon: AlertTriangle,
iconColor: 'text-orange-400', iconColor: 'text-[var(--accent-amber)]',
bgClass: 'bg-orange-400', bgClass: 'bg-[var(--accent-amber)]',
borderClass: 'border-orange-500/30', borderClass: 'border-[var(--accent-amber)]/30',
label: '待确认', label: '待确认',
labelClass: 'text-orange-400', labelClass: 'text-[var(--accent-amber)]',
}; };
} }
}; };

View File

@ -77,10 +77,10 @@ export function TopicTokenStatsPanel({ tokenStats }: TopicTokenStatsPanelProps)
<div <div
className={`h-full rounded-full transition-all ${ className={`h-full rounded-full transition-all ${
pct >= 80 pct >= 80
? 'bg-red-400' ? 'bg-[rgb(242,90,90)]'
: pct >= 50 : pct >= 50
? 'bg-amber-400' ? 'bg-[var(--accent-amber)]'
: 'bg-emerald-400' : 'bg-[var(--accent-green)]'
}`} }`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />

View File

@ -175,7 +175,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
if (!config) if (!config)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="text-red-400 text-center space-y-3"> <div className="text-[rgb(242,90,90)] text-center space-y-3">
<AlertTriangle className="h-10 w-10 mx-auto" /> <AlertTriangle className="h-10 w-10 mx-auto" />
<p>{error || '加载失败'}</p> <p>{error || '加载失败'}</p>
<button <button
@ -227,9 +227,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in">
<div <div
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]" className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
@ -237,7 +237,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<Settings className="h-5 w-5 text-[var(--accent-cyan)]" /> <Settings className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span> <span className="text-lg font-semibold text-[var(--text-primary)]"></span>
{dirty && ( {dirty && (
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full"> <span className="text-xs text-[var(--accent-amber)] bg-[var(--accent-amber)]/10 px-2 py-0.5 rounded-full">
</span> </span>
)} )}
@ -267,9 +267,9 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm transition-all relative ${ className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm rounded-lg transition-colors relative ${
active active
? 'text-[var(--accent-cyan)] bg-[var(--accent-cyan)]/5' ? 'text-[var(--text-primary)] bg-[var(--overlay-subtle)] font-medium'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)]'
}`} }`}
> >
@ -293,7 +293,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Footer */} {/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3"> <div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]/80 backdrop-blur-md flex items-center gap-3">
{error && <span className="text-sm text-red-400 truncate max-w-xs">{error}</span>} {error && <span className="text-sm text-[rgb(242,90,90)] truncate max-w-xs">{error}</span>}
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={handleClose} onClick={handleClose}
@ -304,7 +304,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || restarting || !dirty} disabled={saving || restarting || !dirty}
className="flex items-center gap-2 px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)] hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? '保存中...' : '保存配置'} {saving ? '保存中...' : '保存配置'}
@ -322,7 +322,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-sm shadow-lg backdrop-blur-md animate-[fadeIn_0.2s_ease-out]"> <div className="absolute top-20 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-5 py-3 rounded-xl bg-[var(--accent-green)]/15 border border-[var(--accent-green)]/30 text-[var(--accent-green)] text-sm shadow-lg backdrop-blur-md animate-fade-in">
{restarting ? ( {restarting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@ -335,7 +335,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
{/* Restart confirmation dialog */} {/* Restart confirmation dialog */}
{restartDialogMode && ( {restartDialogMode && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl"> <div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]"> <div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 max-w-sm mx-4 shadow-2xl animate-scale-in">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--accent-cyan)]/10"> <div className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--accent-cyan)]/10">
<RefreshCw className="h-5 w-5 text-[var(--accent-cyan)]" /> <RefreshCw className="h-5 w-5 text-[var(--accent-cyan)]" />
@ -362,7 +362,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
</button> </button>
<button <button
onClick={handleRestart} onClick={handleRestart}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all" className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium text-white bg-[var(--accent-cyan)] hover:opacity-90 transition-opacity"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />

View File

@ -68,7 +68,7 @@ export const TAB_GROUPS: TabGroup[] = [
export const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs); export const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs);
export const inputCls = export const inputCls =
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors'; 'w-full px-3 py-2 rounded-xl bg-[var(--surface-input)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--border-accent)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors';
export const selectCls = inputCls; export const selectCls = inputCls;
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [

View File

@ -145,7 +145,7 @@ export function ExpertModal({ draft, onClose, onSaved, setToast }: Props) {
onClick={onClose} onClick={onClose}
> >
<div <div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden" className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-scale-in max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ModalHeader <ModalHeader

View File

@ -173,7 +173,7 @@ export function SubagentModal({ draft, onClose, onSaved, setToast }: Props) {
onClick={onClose} onClick={onClose}
> >
<div <div
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden" className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-scale-in max-h-[90%] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ModalHeader <ModalHeader

View File

@ -30,7 +30,7 @@ export function AddButton({ label, onClick }: { label: string; onClick: () => vo
export function ErrorBanner({ children }: { children: ReactNode }) { export function ErrorBanner({ children }: { children: ReactNode }) {
if (!children) return null; if (!children) return null;
return ( return (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"> <div className="text-sm text-[rgb(242,90,90)] bg-[rgb(242,90,90)]/10 border border-[rgb(242,90,90)]/25 rounded-lg px-3 py-2">
{children} {children}
</div> </div>
); );

View File

@ -138,7 +138,7 @@ export function ExpertsTab({ config, update, setToast }: Props) {
</button> </button>
<button <button
onClick={() => handleDelete(expert.name)} onClick={() => handleDelete(expert.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors" className="p-1 rounded text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除" title="删除"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@ -84,7 +84,7 @@ export function McpTab({ config, update, setToast }: Props) {
</span> </span>
</div> </div>
{mcpStatus.failed_servers > 0 && ( {mcpStatus.failed_servers > 0 && (
<span className="text-red-400">{mcpStatus.failed_servers} </span> <span className="text-[rgb(242,90,90)]">{mcpStatus.failed_servers} </span>
)} )}
<span className="text-[var(--text-muted)]">{mcpStatus.total_tools} </span> <span className="text-[var(--text-muted)]">{mcpStatus.total_tools} </span>
<button <button
@ -111,10 +111,10 @@ export function McpTab({ config, update, setToast }: Props) {
</span> </span>
) : st.error ? ( ) : st.error ? (
<span <span
className="inline-flex items-center gap-1 text-xs text-red-400" className="inline-flex items-center gap-1 text-xs text-[rgb(242,90,90)]"
title={st.error} title={st.error}
> >
<span className="w-2 h-2 rounded-full bg-red-400" /> <span className="w-2 h-2 rounded-full bg-[rgb(242,90,90)]" />
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 text-xs text-gray-400"> <span className="inline-flex items-center gap-1 text-xs text-gray-400">

View File

@ -52,8 +52,8 @@ function ToolTag({
if (!tools || tools.length === 0) return null; if (!tools || tools.length === 0) return null;
const tagCls = const tagCls =
tone === 'allow' tone === 'allow'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' ? 'bg-[var(--accent-green)]/10 text-[var(--accent-green)]'
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'; : 'bg-[rgb(242,90,90)]/10 text-[rgb(242,90,90)]';
return ( return (
<div className="flex items-center gap-1 flex-wrap mt-1"> <div className="flex items-center gap-1 flex-wrap mt-1">
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span> <span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
@ -235,7 +235,7 @@ export function SubagentsTab({ config, update, setToast }: Props) {
</button> </button>
<button <button
onClick={() => handleDelete(subagent.name)} onClick={() => handleDelete(subagent.name)}
className="p-1 rounded text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors" className="p-1 rounded text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除" title="删除"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@ -401,7 +401,7 @@ export function MapEntryHeader({
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={onDelete} onClick={onDelete}
className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors" className="p-1 rounded text-[rgb(242,90,90)]/60 hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>

View File

@ -23,31 +23,31 @@ function stateBadge(state: string): { label: string; color: string; pulse: boole
case 'running': case 'running':
return { return {
label: '执行中', label: '执行中',
color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', color: 'bg-[var(--accent-amber)]/15 text-[var(--accent-amber)] border-[var(--accent-amber)]/30',
pulse: true, pulse: true,
}; };
case 'scheduled': case 'scheduled':
return { return {
label: '已调度', label: '已调度',
color: 'bg-amber-500/15 text-amber-300 border-amber-500/20', color: 'bg-[var(--accent-amber)]/10 text-[var(--accent-amber)] border-[var(--accent-amber)]/20',
pulse: false, pulse: false,
}; };
case 'paused': case 'paused':
return { return {
label: '已暂停', label: '已暂停',
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false, pulse: false,
}; };
case 'completed': case 'completed':
return { return {
label: '已完成', label: '已完成',
color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', color: 'bg-[var(--accent-green)]/15 text-[var(--accent-green)] border-[var(--accent-green)]/30',
pulse: false, pulse: false,
}; };
default: default:
return { return {
label: state, label: state,
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', color: 'bg-[var(--overlay-dim)] text-[var(--text-muted)] border-[var(--border-color)]',
pulse: false, pulse: false,
}; };
} }
@ -84,9 +84,9 @@ function scheduleDescription(schedule: unknown): string {
function lastStatusIcon(lastStatus: string | undefined) { function lastStatusIcon(lastStatus: string | undefined) {
switch (lastStatus) { switch (lastStatus) {
case 'ok': case 'ok':
return <Check className="h-3 w-3 text-emerald-400" />; return <Check className="h-3 w-3 text-[var(--accent-green)]" />;
case 'error': case 'error':
return <X className="h-3 w-3 text-red-400" />; return <X className="h-3 w-3 text-[rgb(242,90,90)]" />;
default: default:
return <Minus className="h-3 w-3 text-[var(--text-muted)]" />; return <Minus className="h-3 w-3 text-[var(--text-muted)]" />;
} }
@ -157,7 +157,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span <span
className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`} className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'}`}
/> />
{hasLookup && ( {hasLookup && (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" /> <ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
@ -171,7 +171,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`} className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}
> >
{badge.pulse && ( {badge.pulse && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--accent-amber)] animate-pulse" />
)} )}
{badge.label} {badge.label}
</span> </span>
@ -180,9 +180,9 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
<span <span
className={ className={
job.last_status === 'error' job.last_status === 'error'
? 'text-red-400' ? 'text-[rgb(242,90,90)]'
: job.last_status === 'ok' : job.last_status === 'ok'
? 'text-emerald-400' ? 'text-[var(--accent-green)]'
: 'text-[var(--text-muted)]' : 'text-[var(--text-muted)]'
} }
> >
@ -199,7 +199,7 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
{/* Row 3: Error message (if any) */} {/* Row 3: Error message (if any) */}
{job.last_error && ( {job.last_error && (
<div className="mb-1.5 text-xs text-red-400/80 truncate bg-red-500/10 rounded px-1.5 py-0.5"> <div className="mb-1.5 text-xs text-[rgb(242,90,90)] truncate bg-[rgb(242,90,90)]/10 rounded px-1.5 py-0.5">
{job.last_error} {job.last_error}
</div> </div>
)} )}

View File

@ -12,7 +12,7 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Wifi className="h-4 w-4 text-[var(--accent-cyan)]" /> <Wifi className="h-4 w-4 text-[var(--accent-cyan)]" />
<span className="text-sm font-medium text-[var(--text-primary)]">WebSocket</span> <span className="text-sm font-medium text-[var(--text-primary)]">WebSocket</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400"> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)]">
线 线
</span> </span>
</div> </div>

View File

@ -158,10 +158,10 @@ export function TopicList({
<button <button
onClick={onCreateTopic} onClick={onCreateTopic}
disabled={!sessionId} disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-all ${ className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
!sessionId !sessionId
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed' ? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
: 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30' : 'bg-[var(--accent-cyan)] text-white hover:opacity-90'
}`} }`}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
@ -215,7 +215,7 @@ export function TopicList({
<button <button
type="submit" type="submit"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center h-6 w-6 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors shrink-0" className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors shrink-0"
title="确认 (Enter)" title="确认 (Enter)"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@ -224,7 +224,7 @@ export function TopicList({
type="button" type="button"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={cancelEdit} onClick={cancelEdit}
className="flex items-center justify-center h-6 w-6 rounded-md bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors shrink-0" className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors shrink-0"
title="取消 (Esc)" title="取消 (Esc)"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -234,21 +234,21 @@ export function TopicList({
<> <>
<button <button
onClick={() => onSwitchTopic(topic.id)} onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-all ${ className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-colors ${
topic.id === currentTopicId topic.id === currentTopicId
? 'bg-gradient-to-r from-[var(--accent-cyan)]/20 to-transparent border border-[var(--accent-cyan)]/30' ? 'bg-[var(--overlay-subtle)] border border-[var(--border-color)]'
: 'hover:bg-[var(--overlay-hover)] border border-transparent' : 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] font-mono w-4"> <span className="mt-0.5 text-xs text-[var(--text-muted)] w-4">
{currentPage * pageSize + index + 1} {currentPage * pageSize + index + 1}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div <div
className={`truncate font-medium ${ className={`truncate font-medium ${
topic.id === currentTopicId topic.id === currentTopicId
? 'text-[var(--accent-cyan)]' ? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]' : 'text-[var(--text-secondary)]'
}`} }`}
> >
@ -266,7 +266,7 @@ export function TopicList({
</div> </div>
</div> </div>
{topic.id === currentTopicId && ( {topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] shadow-lg shadow-[var(--shadow-glow-soft)] mt-1.5" /> <span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] mt-1.5" />
)} )}
</div> </div>
</button> </button>
@ -275,14 +275,16 @@ export function TopicList({
<div className="absolute top-2.5 right-2.5"> <div className="absolute top-2.5 right-2.5">
{confirmDeleteId === topic.id ? ( {confirmDeleteId === topic.id ? (
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in"> <span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
<span className="text-xs text-red-400 whitespace-nowrap">?</span> <span className="text-xs text-[rgb(242,90,90)] whitespace-nowrap">
?
</span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDeleteTopic(topic.id); onDeleteTopic(topic.id);
setConfirmDeleteId(null); setConfirmDeleteId(null);
}} }}
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors" className="flex items-center justify-center h-5 w-5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors"
title="确认" title="确认"
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
@ -292,7 +294,7 @@ export function TopicList({
e.stopPropagation(); e.stopPropagation();
setConfirmDeleteId(null); setConfirmDeleteId(null);
}} }}
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors" className="flex items-center justify-center h-5 w-5 rounded bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors"
title="取消" title="取消"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@ -305,7 +307,7 @@ export function TopicList({
e.stopPropagation(); e.stopPropagation();
startEdit(topic); startEdit(topic);
}} }}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors" className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="重命名话题" title="重命名话题"
> >
<Edit2 className="h-3.5 w-3.5" /> <Edit2 className="h-3.5 w-3.5" />
@ -315,7 +317,7 @@ export function TopicList({
e.stopPropagation(); e.stopPropagation();
setConfirmDeleteId(topic.id); setConfirmDeleteId(topic.id);
}} }}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors" className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除话题" title="删除话题"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />

View File

@ -2,54 +2,62 @@
/* ============================================ /* ============================================
PicoBot Theme Dark (default) PicoBot Theme Dark (default)
对齐 deepseek-harness 设计语言
--accent-cyan 语义为品牌主色 DeepSeek blue
============================================ */ ============================================ */
:root { :root {
/* Core colors */ /* Core colors */
--bg-primary: #0a0a0f; --bg-primary: rgb(35, 35, 36);
--bg-secondary: #12121a; --bg-secondary: rgb(27, 27, 28);
--bg-tertiary: #1a1a25; --bg-tertiary: rgb(44, 44, 46);
--bg-hover: #252535; --bg-hover: rgba(255, 255, 255, 0.08);
/* Accent colors */ /* Accent colors */
--accent-cyan: #00f0ff; --accent-cyan: rgb(103, 158, 254);
--accent-blue: #3b82f6; --accent-blue: rgb(96, 165, 250);
--accent-purple: #8b5cf6; --accent-purple: rgb(183, 200, 254);
--accent-green: #10b981; --accent-green: rgb(78, 209, 126);
--accent-amber: #f59e0b; --accent-amber: rgb(247, 173, 49);
/* Text colors */ /* Text colors */
--text-primary: #ffffff; --text-primary: rgb(249, 250, 251);
--text-secondary: #a1a1aa; --text-secondary: rgb(207, 211, 214);
--text-muted: #71717a; --text-muted: rgb(151, 157, 166);
--text-accent: var(--accent-cyan); --text-accent: var(--accent-cyan);
/* Border */ /* Border */
--border-color: rgba(255, 255, 255, 0.08); --border-color: rgba(255, 255, 255, 0.12);
--border-accent: rgba(0, 240, 255, 0.3); --border-accent: rgba(103, 158, 254, 0.4);
/* Overlay — for hover / subtle surface effects */ /* Overlay — for hover / subtle surface effects */
--overlay-hover: rgba(255, 255, 255, 0.05); --overlay-hover: rgba(255, 255, 255, 0.08);
--overlay-subtle: rgba(255, 255, 255, 0.1); --overlay-subtle: rgba(255, 255, 255, 0.14);
--overlay-medium: rgba(255, 255, 255, 0.2); --overlay-medium: rgba(255, 255, 255, 0.24);
--overlay-dim: rgba(0, 0, 0, 0.2); --overlay-dim: rgba(0, 0, 0, 0.2);
--overlay-dim-strong: rgba(0, 0, 0, 0.3); --overlay-dim-strong: rgba(0, 0, 0, 0.3);
--overlay-dim-heavy: rgba(0, 0, 0, 0.4); --overlay-dim-heavy: rgba(0, 0, 0, 0.4);
--overlay-code: rgba(0, 0, 0, 0.3); --overlay-code: rgba(0, 0, 0, 0.3);
/* Shadows */ /* Shadows */
--shadow-glow-sm: rgba(0, 240, 255, 0.12); --shadow-glow-sm: rgba(103, 158, 254, 0.1);
--shadow-glow: rgba(0, 240, 255, 0.2); --shadow-glow: rgba(103, 158, 254, 0.16);
--shadow-glow-strong: rgba(0, 240, 255, 0.3); --shadow-glow-strong: rgba(103, 158, 254, 0.24);
--shadow-glow-soft: rgba(0, 240, 255, 0.5); --shadow-glow-soft: rgba(103, 158, 254, 0.32);
/* Focus ring */ /* Focus ring */
--focus-ring: rgba(0, 240, 255, 0.2); --focus-ring: rgba(103, 158, 254, 0.3);
/* Selection / code highlight */ /* Selection / code highlight */
--selection-bg: rgba(0, 240, 255, 0.3); --selection-bg: rgba(103, 158, 254, 0.28);
/* Divider */ /* Divider */
--divider-color: rgba(255, 255, 255, 0.2); --divider-color: rgba(255, 255, 255, 0.08);
/* Chat composer / user bubble surfaces */
--bubble-user: rgb(44, 44, 46);
--surface-input: rgb(44, 44, 46);
--btn-contrast: rgb(249, 250, 251);
--btn-contrast-fg: rgb(27, 27, 28);
color-scheme: dark; color-scheme: dark;
} }
@ -59,51 +67,57 @@
============================================ */ ============================================ */
html.light { html.light {
/* Backgrounds */ /* Backgrounds */
--bg-primary: #f5f5f7; --bg-primary: rgb(255, 255, 255);
--bg-secondary: #ffffff; --bg-secondary: rgb(249, 250, 251);
--bg-tertiary: #ebecf0; --bg-tertiary: rgb(241, 243, 245);
--bg-hover: #dfe0e5; --bg-hover: rgba(38, 49, 72, 0.06);
/* Accents — slightly darker for readability on white */ /* Accents */
--accent-cyan: #008899; --accent-cyan: rgb(65, 118, 230);
--accent-blue: #2563eb; --accent-blue: rgb(59, 130, 246);
--accent-purple: #7c3aed; --accent-purple: rgb(65, 118, 230);
--accent-green: #059669; --accent-green: rgb(34, 197, 94);
--accent-amber: #d97706; --accent-amber: rgb(245, 158, 11);
/* Text */ /* Text */
--text-primary: #1a1a2e; --text-primary: rgb(15, 17, 21);
--text-secondary: #52525b; --text-secondary: rgb(97, 102, 107);
--text-muted: #a1a1aa; --text-muted: rgb(151, 157, 166);
--text-accent: var(--accent-cyan); --text-accent: var(--accent-cyan);
/* Borders */ /* Borders */
--border-color: rgba(0, 0, 0, 0.08); --border-color: rgba(0, 0, 0, 0.1);
--border-accent: rgba(0, 136, 153, 0.3); --border-accent: rgba(65, 118, 230, 0.4);
/* Overlays */ /* Overlays */
--overlay-hover: rgba(0, 0, 0, 0.04); --overlay-hover: rgba(38, 49, 72, 0.06);
--overlay-subtle: rgba(0, 0, 0, 0.06); --overlay-subtle: rgba(38, 49, 72, 0.1);
--overlay-medium: rgba(0, 0, 0, 0.1); --overlay-medium: rgba(38, 49, 72, 0.16);
--overlay-dim: rgba(0, 0, 0, 0.04); --overlay-dim: rgba(0, 0, 0, 0.04);
--overlay-dim-strong: rgba(0, 0, 0, 0.06); --overlay-dim-strong: rgba(0, 0, 0, 0.06);
--overlay-dim-heavy: rgba(0, 0, 0, 0.08); --overlay-dim-heavy: rgba(0, 0, 0, 0.08);
--overlay-code: rgba(0, 0, 0, 0.06); --overlay-code: rgba(0, 0, 0, 0.06);
/* Shadows — softer */ /* Shadows — softer */
--shadow-glow-sm: rgba(0, 136, 153, 0.1); --shadow-glow-sm: rgba(65, 118, 230, 0.08);
--shadow-glow: rgba(0, 136, 153, 0.15); --shadow-glow: rgba(65, 118, 230, 0.12);
--shadow-glow-strong: rgba(0, 136, 153, 0.22); --shadow-glow-strong: rgba(65, 118, 230, 0.18);
--shadow-glow-soft: rgba(0, 136, 153, 0.35); --shadow-glow-soft: rgba(65, 118, 230, 0.25);
/* Focus ring */ /* Focus ring */
--focus-ring: rgba(0, 136, 153, 0.2); --focus-ring: rgba(65, 118, 230, 0.25);
/* Selection / code highlight */ /* Selection / code highlight */
--selection-bg: rgba(0, 136, 153, 0.2); --selection-bg: rgba(65, 118, 230, 0.2);
/* Divider */ /* Divider */
--divider-color: rgba(0, 0, 0, 0.1); --divider-color: rgba(0, 0, 0, 0.08);
/* Chat composer / user bubble surfaces */
--bubble-user: rgb(237, 243, 254);
--surface-input: rgb(255, 255, 255);
--btn-contrast: rgb(67, 69, 74);
--btn-contrast-fg: rgb(255, 255, 255);
color-scheme: light; color-scheme: light;
} }
@ -122,6 +136,11 @@ html.theme-transitioning *::after {
text-shadow 0.3s ease !important; text-shadow 0.3s ease !important;
} }
/* 虚拟列表行使用内联 transform 定位,主题过渡期间禁止对其做 transition避免行位移被动画化 */
html.theme-transitioning [data-index] {
transition: none !important;
}
/* ============================================ /* ============================================
Custom scrollbar Custom scrollbar
============================================ */ ============================================ */
@ -135,12 +154,20 @@ html.theme-transitioning *::after {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--bg-tertiary); background: rgb(101, 103, 107);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--accent-blue); background: rgb(129, 133, 140);
}
html.light ::-webkit-scrollbar-thumb {
background: rgb(229, 229, 229);
}
html.light ::-webkit-scrollbar-thumb:hover {
background: rgb(212, 212, 212);
} }
/* Hide scrollbar utility */ /* Hide scrollbar utility */
@ -163,12 +190,15 @@ html.theme-transitioning *::after {
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace; font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary); background-color: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
font-size: 15px;
} }
/* ============================================ /* ============================================
@ -180,13 +210,10 @@ body {
} }
/* ============================================ /* ============================================
Glowing text effect Glowing text effectDeepSeek 风格下仅保留品牌色无发光
============================================ */ ============================================ */
.glow-text { .glow-text {
text-shadow: color: var(--accent-cyan);
0 0 10px var(--shadow-glow-soft),
0 0 20px var(--shadow-glow-strong),
0 0 30px var(--shadow-glow-sm);
} }
/* ============================================ /* ============================================
@ -202,7 +229,7 @@ body {
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
padding: 1px; padding: 1px;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); background: var(--accent-cyan);
-webkit-mask: -webkit-mask:
linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0); linear-gradient(#fff 0 0);
@ -219,16 +246,10 @@ body {
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 0%,
100% { 100% {
box-shadow: box-shadow: 0 0 0 1px var(--shadow-glow);
0 0 5px var(--accent-cyan),
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan);
} }
50% { 50% {
box-shadow: box-shadow: 0 0 0 3px var(--shadow-glow);
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan),
0 0 40px var(--accent-cyan);
} }
} }
@ -330,7 +351,7 @@ body {
background-color: transparent; background-color: transparent;
} }
50% { 50% {
background-color: rgba(0, 240, 255, 0.15); background-color: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
} }
} }
@ -405,9 +426,9 @@ body {
Code block styling Code block styling
============================================ */ ============================================ */
pre { pre {
background: var(--bg-tertiary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 12px;
padding: 12px; padding: 12px;
overflow-x: auto; overflow-x: auto;
} }
@ -427,6 +448,12 @@ textarea:focus {
box-shadow: 0 0 0 2px var(--focus-ring); box-shadow: 0 0 0 2px var(--focus-ring);
} }
/* Composer 内的无边框 textarea聚焦高亮由外层卡片 focus-within 提供 */
textarea.composer-area:focus {
border-color: transparent;
box-shadow: none;
}
/* ============================================ /* ============================================
Button hover effects Button hover effects
============================================ */ ============================================ */
@ -434,14 +461,6 @@ button {
transition: all 0.2s ease; transition: all 0.2s ease;
} }
button:hover:not(:disabled) {
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* ============================================ /* ============================================
Link styles Link styles
============================================ */ ============================================ */
@ -452,5 +471,5 @@ a {
} }
a:hover { a:hover {
text-shadow: 0 0 8px var(--accent-cyan); opacity: 0.85;
} }

View File

@ -17,7 +17,7 @@ export function contextOccupancyPct(stats: TopicTokenStats): number | null {
/** 根据占用率返回颜色 class */ /** 根据占用率返回颜色 class */
export function occupancyColor(pct: number): string { export function occupancyColor(pct: number): string {
if (pct >= 80) return 'text-red-400'; if (pct >= 80) return 'text-[rgb(242,90,90)]';
if (pct >= 50) return 'text-amber-400'; if (pct >= 50) return 'text-[var(--accent-amber)]';
return 'text-emerald-400'; return 'text-[var(--accent-green)]';
} }