feat: TodoPanel 改为常驻右边栏并支持点击跳转

- TodoPanel 移除浮动定位与拖动逻辑,改为常驻右边栏
- 右边栏与左边栏对称的折叠/展开逻辑,localStorage 持久化
- 待办/记忆/技能三 Tab 切换
- 点击已完成 todo 滚动并高亮对应 tool 消息
- 修复 MessageBubble isMergedTool 分支缺失 data-message-id 导致跳转失效
- ChatContainer 移除 todoPanel prop
This commit is contained in:
oudecheng 2026-07-03 19:32:46 +08:00
parent 4766f838a2
commit cb0d3d932d
5 changed files with 177 additions and 274 deletions

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, PanelLeftClose, PanelLeftOpen, X, Brain, Settings as SettingsIcon, ChevronRight } from 'lucide-react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelLeftClose, PanelLeftOpen, Brain, Settings as SettingsIcon, ChevronRight } 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'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@ -103,22 +103,25 @@ function App() {
setSendMessage(sendMessage) setSendMessage(sendMessage)
}, [setSendMessage, sendMessage]) }, [setSendMessage, sendMessage])
// ---- 主题状态 ---- // ---- 右边栏状态(与左边栏对称的折叠/展开逻辑) ----
const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => { const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => {
try { try {
return localStorage.getItem('picobot-memory-panel-open') !== 'false' return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'
} catch { } catch {
return false return false
} }
}) })
const toggleMemoryPanel = useCallback((open: boolean) => { const toggleRightSidebar = useCallback(() => {
setMemoryPanelOpen(open) setRightSidebarCollapsed(prev => {
localStorage.setItem('picobot-memory-panel-open', String(open)) const next = !prev
localStorage.setItem('picobot-right-sidebar-collapsed', String(next))
return next
})
}, []) }, [])
const [rightPanelTab, setRightPanelTab] = useState<'memory' | 'skill'>('memory') const [rightPanelTab, setRightPanelTab] = useState<'todo' | 'memory' | 'skill'>('todo')
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
try { try {
@ -417,15 +420,25 @@ function App() {
// 点击待办项后滚动到对应消息 // 点击待办项后滚动到对应消息
const handleTodoClick = useCallback((todo: TodoItemSummary) => { const handleTodoClick = useCallback((todo: TodoItemSummary) => {
if (todo.created_by_message_id) { if (!todo.created_by_message_id) {
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
setHighlightedMessageId(null)
const msgId = todo.created_by_message_id
setTimeout(() => setHighlightedMessageId(msgId), 0)
} else {
alert('该待办的完成记录无法定位,可能是历史数据') alert('该待办的完成记录无法定位,可能是历史数据')
return
} }
}, [setHighlightedMessageId]) // 若处于子智能体视图或定时任务视图,先退出回到主会话视图
if (subAgentStack.length > 0) {
navigateToSubAgentLevel(-1)
}
if (schedulerView) {
exitSchedulerJobView()
}
// 先清再设,确保同一 todo 重复点击也能触发 useEffect
const msgId = todo.created_by_message_id
setHighlightedMessageId(null)
// 延迟一帧,等视图切换后消息列表渲染完成再滚动
setTimeout(() => {
setHighlightedMessageId(msgId)
}, 50)
}, [setHighlightedMessageId, subAgentStack.length, schedulerView, navigateToSubAgentLevel, exitSchedulerJobView])
const handleRefreshSchedulerJobs = useCallback(() => { const handleRefreshSchedulerJobs = useCallback(() => {
const cmd = requestSchedulerJobList() const cmd = requestSchedulerJobList()
@ -780,23 +793,38 @@ function App() {
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey} viewKey={viewKey}
highlightedMessageId={highlightedMessageId} highlightedMessageId={highlightedMessageId}
todoPanel={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
}
/> />
</div> </div>
</div> </div>
{/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */} {/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
<div className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 transition-all duration-300 ease-out overflow-hidden ${memoryPanelOpen ? 'w-80' : 'w-0 border-l-0'}`}> <div
<div className={`w-80 h-full flex flex-col ${memoryPanelOpen ? '' : 'invisible'}`}> className={`shrink-0 border-l border-[var(--border-color)] bg-[var(--bg-secondary)]/50 flex flex-col overflow-hidden ${rightSidebarCollapsed ? 'w-11' : 'w-80'}`}
style={{ transition: 'width 200ms ease-out', willChange: 'width' }}
>
{rightSidebarCollapsed ? (
// 折叠态:窄条 + 展开按钮
<button
onClick={toggleRightSidebar}
className="flex items-center justify-center w-full py-4 text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="展开侧栏"
>
<PanelLeftOpen className="h-4 w-4 rotate-180" />
</button>
) : (
<div className="w-80 h-full flex flex-col">
{/* Tab 栏 */} {/* Tab 栏 */}
<div className="shrink-0 flex border-b border-[var(--border-color)]"> <div className="shrink-0 flex border-b border-[var(--border-color)]">
<button
onClick={() => setRightPanelTab('todo')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
rightPanelTab === 'todo'
? 'text-[var(--accent-cyan)] border-b-2 border-[var(--accent-cyan)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
}`}
>
</button>
<button <button
onClick={() => setRightPanelTab('memory')} onClick={() => setRightPanelTab('memory')}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${ className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
@ -817,13 +845,20 @@ function App() {
> >
</button> </button>
<button onClick={() => toggleMemoryPanel(false)} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起"> <button onClick={toggleRightSidebar} className="px-2 py-2.5 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors shrink-0" title="收起">
<X className="h-3.5 w-3.5" /> <PanelLeftClose className="h-3.5 w-3.5 rotate-180" />
</button> </button>
</div> </div>
{/* Panel content */} {/* Panel content */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{rightPanelTab === 'memory' ? ( {rightPanelTab === 'todo' ? (
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
) : rightPanelTab === 'memory' ? (
<MemoryPanel <MemoryPanel
memories={memories} memories={memories}
onRefresh={handleRefreshMemories} onRefresh={handleRefreshMemories}
@ -840,21 +875,9 @@ function App() {
)} )}
</div> </div>
</div> </div>
</div>
{/* Reopen button — visible when panel is collapsed */}
{!memoryPanelOpen && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
<button
onClick={() => toggleMemoryPanel(true)}
className="flex items-center gap-1.5 px-2 py-4 rounded-l-xl bg-[var(--bg-secondary)]/80 backdrop-blur-sm border border-r-0 border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg"
title="展开记忆面板"
>
<PanelRightOpen className="h-4 w-4" />
</button>
</div>
)} )}
</div> </div>
</div>
{/* 系统配置页面 */} {/* 系统配置页面 */}
{configPageOpen && ( {configPageOpen && (

View File

@ -11,8 +11,6 @@ interface ChatContainerProps {
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onStop?: () => void onStop?: () => void
showThinking?: boolean showThinking?: boolean
/** 浮动待办面板,绝对定位在消息区域上方 */
todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */ /** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string viewKey?: string
/** 高亮的消息 ID */ /** 高亮的消息 ID */
@ -28,7 +26,6 @@ export function ChatContainer({
onNavigateToSubAgent, onNavigateToSubAgent,
onStop, onStop,
showThinking = true, showThinking = true,
todoPanel,
viewKey, viewKey,
highlightedMessageId, highlightedMessageId,
}: ChatContainerProps) { }: ChatContainerProps) {
@ -36,7 +33,6 @@ export function ChatContainer({
<div className="flex h-full flex-col relative"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} /> <MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
{todoPanel}
</div> </div>
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}

View File

@ -376,7 +376,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
} as const } as const
return ( return (
<div className="flex gap-3 animate-slide-in"> <div data-message-id={message.id} className="flex gap-3 animate-slide-in">
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full mt-0.5 ${ <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 ? 'bg-violet-500/20' : statusConfig.avatarBg
}`}> }`}>

View File

@ -127,14 +127,10 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
const container = containerRef.current const container = containerRef.current
if (!container) return if (!container) return
// 查找目标消息元素
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`) const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
if (!targetElement) return if (!targetElement) return
// 滚动到目标位置
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 添加高亮样式
targetElement.classList.add('todo-highlight') targetElement.classList.add('todo-highlight')
setTimeout(() => { setTimeout(() => {
targetElement.classList.remove('todo-highlight') targetElement.classList.remove('todo-highlight')

View File

@ -47,45 +47,24 @@ function PulseDot() {
) )
} }
/* ── position persistence ─────────────────────────────── */
const POS_KEY = 'picobot-todo-pos'
function loadPos(): { x: number; y: number } {
try {
const raw = localStorage.getItem(POS_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return { x: 0, y: 0 }
}
function savePos(pos: { x: number; y: number }) {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)) } catch { /* ignore */ }
}
/* ── TodoPanel ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) { export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
const [expanded, setExpanded] = useState(() => {
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
})
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled'])) const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled']))
const [pos, setPos] = useState(loadPos)
const prevTodoIdsRef = useRef<Set<string>>(new Set()) const prevTodoIdsRef = useRef<Set<string>>(new Set())
const dragRef = useRef<{ startX: number; startY: number; startPos: { x: number; y: number }; moved: boolean } | null>(null)
useEffect(() => { // 新增待办时自动展开"进行中"分组
localStorage.setItem('picobot-todo-expanded', String(expanded))
}, [expanded])
// auto-expand on new items, auto-collapse when empty
useEffect(() => { useEffect(() => {
const newIds = new Set(todos.map(t => t.id)) const newIds = new Set(todos.map(t => t.id))
if (todos.length === 0) { if (todos.length > 0) {
setExpanded(false)
} else {
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems) setExpanded(true) if (hasNewItems) {
setCollapsedGroups(prev => {
const next = new Set(prev)
next.delete('in_progress')
return next
})
}
} }
prevTodoIdsRef.current = newIds prevTodoIdsRef.current = newIds
}, [todos]) }, [todos])
@ -104,97 +83,10 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList]) const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
/* ── drag handling ──────────────────────────────────── */
const handleDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return
e.preventDefault()
const startX = e.clientX
const startY = e.clientY
const startPos = { ...pos }
dragRef.current = { startX, startY, startPos, moved: false }
const handleMove = (ev: MouseEvent) => {
if (!dragRef.current) return
const dx = ev.clientX - dragRef.current.startX
const dy = ev.clientY - dragRef.current.startY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragRef.current.moved = true
setPos({
x: Math.max(0, dragRef.current.startPos.x - dx),
y: Math.max(0, dragRef.current.startPos.y + dy),
})
}
const handleUp = () => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
if (dragRef.current) {
setPos(prev => { savePos(prev); return prev })
dragRef.current = null
}
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'grabbing'
}, [pos])
/* ── minimized: circle button ───────────────────────── */
if (!expanded) {
return ( return (
<div <div className="flex h-full flex-col">
className="absolute z-30" {/* title bar */}
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }} <div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
>
<div
className="relative cursor-grab active:cursor-grabbing select-none"
onMouseDown={handleDragStart}
>
<button
onClick={() => { if (totalCount > 0) setExpanded(true); else handleRefresh() }}
className="relative w-14 h-14 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-xl border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/50 shadow-[0_4px_24px_rgba(0,240,255,0.08)] hover:shadow-[0_4px_32px_var(--shadow-glow-sm)] transition-all duration-300 group"
title="待办"
>
<div className="flex items-center justify-center">
<ClipboardList className="h-5 w-5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
</div>
{totalCount > 0 && (
<span className={`absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1 rounded-full flex items-center justify-center text-[11px] font-bold leading-tight ${
inProgressCount > 0
? 'bg-amber-400 text-black shadow-[0_0_12px_rgba(245,158,11,0.4)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'
}`}>
{totalCount}
</span>
)}
{inProgressCount > 0 && (
<span className="absolute -inset-[3px] rounded-full animate-todo-ring-pulse" />
)}
</button>
</div>
</div>
)
}
/* ── expanded: full card ────────────────────────────── */
return (
<div
className="absolute z-30"
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
>
<div className="w-80 max-h-[55vh] flex flex-col rounded-2xl bg-[var(--bg-tertiary)]/95 backdrop-blur-md border border-[var(--border-color)] shadow-2xl hover:shadow-[0_8px_40px_var(--shadow-glow-sm)] overflow-hidden animate-todo-card-in">
{/* title bar (drag handle) */}
<div
className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50 cursor-grab active:cursor-grabbing select-none rounded-t-2xl"
onMouseDown={handleDragStart}
>
<span className="text-[var(--text-muted)]/40 text-sm tracking-[0.15em] leading-none select-none group-hover:text-[var(--accent-cyan)]/60 transition-colors"></span>
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" /> <ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span> <span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span>
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span> <span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
@ -203,9 +95,6 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
<button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新"> <button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新">
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
<button onClick={() => setExpanded(false)} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors" title="缩小">
<ChevronDown className="h-4 w-4" />
</button>
</div> </div>
</div> </div>
@ -268,6 +157,5 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
})} })}
</div> </div>
</div> </div>
</div>
) )
} }