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 ( -
-
-
- ) - } - /* ── expanded: full card ────────────────────────────── */ - - return ( -
-
- {/* title bar (drag handle) */} -
- - - 待办 - {totalCount} - {inProgressCount > 0 && } -
- - -
-
- - {/* list */} -
- {totalCount === 0 && ( -
-
-
- -
-

- 暂无待办事项 -

-

- AI 助手可在对话中创建和管理待办 -

+ {/* list */} +
+ {totalCount === 0 && ( +
+
+
+
- )} +

+ 暂无待办事项 +

+

+ AI 助手可在对话中创建和管理待办 +

+
+ )} - {GROUP_ORDER.map(status => { - const items = grouped.get(status) - if (!items || items.length === 0) return null + {GROUP_ORDER.map(status => { + const items = grouped.get(status) + if (!items || items.length === 0) return null - const cfg = statusCfg(status) - const isCollapsed = collapsedGroups.has(status) + const cfg = statusCfg(status) + const isCollapsed = collapsedGroups.has(status) - return ( -
- + return ( +
+ -
-
- {items.map(item => ( - - ))} -
+
+
+ {items.map(item => ( + + ))}
- ) - })} -
+
+ ) + })}
)