From cb0d3d932da39100d18cd491bfb77773a62a7f2c Mon Sep 17 00:00:00 2001
From: oudecheng <13802883547@139.com>
Date: Fri, 3 Jul 2026 19:32:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20TodoPanel=20=E6=94=B9=E4=B8=BA=E5=B8=B8?=
=?UTF-8?q?=E9=A9=BB=E5=8F=B3=E8=BE=B9=E6=A0=8F=E5=B9=B6=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E7=82=B9=E5=87=BB=E8=B7=B3=E8=BD=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- TodoPanel 移除浮动定位与拖动逻辑,改为常驻右边栏
- 右边栏与左边栏对称的折叠/展开逻辑,localStorage 持久化
- 待办/记忆/技能三 Tab 切换
- 点击已完成 todo 滚动并高亮对应 tool 消息
- 修复 MessageBubble isMergedTool 分支缺失 data-message-id 导致跳转失效
- ChatContainer 移除 todoPanel prop
---
web/src/App.tsx | 187 +++++++++-------
web/src/components/Chat/ChatContainer.tsx | 4 -
web/src/components/Chat/MessageBubble.tsx | 2 +-
web/src/components/Chat/MessageList.tsx | 6 +-
web/src/components/Panel/TodoPanel.tsx | 252 ++++++----------------
5 files changed, 177 insertions(+), 274 deletions(-)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index bc91dbe..69365a1 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,5 +1,5 @@
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 { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@@ -103,22 +103,25 @@ function App() {
setSendMessage(sendMessage)
}, [setSendMessage, sendMessage])
- // ---- 主题状态 ----
+ // ---- 右边栏状态(与左边栏对称的折叠/展开逻辑) ----
- const [memoryPanelOpen, setMemoryPanelOpen] = useState(() => {
+ const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(() => {
try {
- return localStorage.getItem('picobot-memory-panel-open') !== 'false'
+ return localStorage.getItem('picobot-right-sidebar-collapsed') === 'true'
} catch {
return false
}
})
- const toggleMemoryPanel = useCallback((open: boolean) => {
- setMemoryPanelOpen(open)
- localStorage.setItem('picobot-memory-panel-open', String(open))
+ const toggleRightSidebar = useCallback(() => {
+ setRightSidebarCollapsed(prev => {
+ 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(() => {
try {
@@ -417,15 +420,25 @@ function App() {
// 点击待办项后滚动到对应消息
const handleTodoClick = useCallback((todo: TodoItemSummary) => {
- if (todo.created_by_message_id) {
- // 先清再设,确保同一 todo 重复点击也能触发 useEffect
- setHighlightedMessageId(null)
- const msgId = todo.created_by_message_id
- setTimeout(() => setHighlightedMessageId(msgId), 0)
- } else {
+ if (!todo.created_by_message_id) {
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 cmd = requestSchedulerJobList()
@@ -780,80 +793,90 @@ function App() {
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
- todoPanel={
-
- }
/>
- {/* Right Sidebar - Memory & Skill Panel (collapsible, tabbed) */}
-
-
- {/* Tab 栏 */}
-
-
-
-
-
- {/* Panel content */}
-
- {rightPanelTab === 'memory' ? (
-
- ) : (
-
- )}
-
-
-
-
- {/* Reopen button — visible when panel is collapsed */}
- {!memoryPanelOpen && (
-
+ {/* Right Sidebar - Todo / Memory / Skill Panel (collapsible, tabbed) */}
+
+ {rightSidebarCollapsed ? (
+ // 折叠态:窄条 + 展开按钮
-
- )}
+ ) : (
+
+ {/* Tab 栏 */}
+
+
+
+
+
+
+ {/* Panel content */}
+
+ {rightPanelTab === 'todo' ? (
+
+ ) : rightPanelTab === 'memory' ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
{/* 系统配置页面 */}
diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx
index 5e8da44..8d3337e 100644
--- a/web/src/components/Chat/ChatContainer.tsx
+++ b/web/src/components/Chat/ChatContainer.tsx
@@ -11,8 +11,6 @@ interface ChatContainerProps {
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onStop?: () => void
showThinking?: boolean
- /** 浮动待办面板,绝对定位在消息区域上方 */
- todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string
/** 高亮的消息 ID */
@@ -28,7 +26,6 @@ export function ChatContainer({
onNavigateToSubAgent,
onStop,
showThinking = true,
- todoPanel,
viewKey,
highlightedMessageId,
}: ChatContainerProps) {
@@ -36,7 +33,6 @@ export function ChatContainer({
- {todoPanel}
+
diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx
index 9bdb780..5b9b6f1 100644
--- a/web/src/components/Chat/MessageList.tsx
+++ b/web/src/components/Chat/MessageList.tsx
@@ -123,18 +123,14 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
useEffect(() => {
if (!highlightedMessageId) return
-
+
const container = containerRef.current
if (!container) return
- // 查找目标消息元素
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
if (!targetElement) return
- // 滚动到目标位置
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
-
- // 添加高亮样式
targetElement.classList.add('todo-highlight')
setTimeout(() => {
targetElement.classList.remove('todo-highlight')
diff --git a/web/src/components/Panel/TodoPanel.tsx b/web/src/components/Panel/TodoPanel.tsx
index 68b5a9e..96d74d6 100644
--- a/web/src/components/Panel/TodoPanel.tsx
+++ b/web/src/components/Panel/TodoPanel.tsx
@@ -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 ────────────────────────────────────────── */
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
>(() => new Set(['completed', 'cancelled']))
- const [pos, setPos] = useState(loadPos)
const prevTodoIdsRef = useRef>(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(() => {
const newIds = new Set(todos.map(t => t.id))
- if (todos.length === 0) {
- setExpanded(false)
- } else {
+ if (todos.length > 0) {
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
}, [todos])
@@ -104,169 +83,78 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
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 (
-
-
-