diff --git a/src/command/handlers/list_todos.rs b/src/command/handlers/list_todos.rs index c297b6d..07e3dda 100644 --- a/src/command/handlers/list_todos.rs +++ b/src/command/handlers/list_todos.rs @@ -76,6 +76,7 @@ impl CommandHandler for ListTodosCommandHandler { id: r.id, content: r.content, status: r.status, + created_by_message_id: r.created_by_message_id, }) .collect(); diff --git a/src/gateway/session.rs b/src/gateway/session.rs index f089479..87ccc7c 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -200,6 +200,7 @@ impl BusToolCallEmitter { priority: "medium".to_string(), created_at: now + idx as i64, updated_at: now, + created_by_message_id: Some(message.id.clone()), }) }) .collect(); diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index a467e29..2cbbcf6 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -88,6 +88,7 @@ pub struct TodoItemSummary { pub id: String, pub content: String, pub status: String, + pub created_by_message_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/storage/mod.rs b/src/storage/mod.rs index d790765..a17e546 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1517,7 +1517,7 @@ impl SessionStore { pub fn list_todos(&self, scope_key: &str) -> Result, StorageError> { let conn = self.pool.get()?; let mut stmt = conn.prepare( - "SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at + "SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id FROM todos WHERE scope_key = ?1 ORDER BY created_at ASC", @@ -1534,6 +1534,7 @@ impl SessionStore { priority: row.get(6)?, created_at: row.get(7)?, updated_at: row.get(8)?, + created_by_message_id: row.get(9)?, }) })?; @@ -1913,6 +1914,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> { priority TEXT NOT NULL DEFAULT 'medium', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + created_by_message_id TEXT, PRIMARY KEY (id, scope_key) ); @@ -1953,6 +1955,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> { priority TEXT NOT NULL DEFAULT 'medium', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + created_by_message_id TEXT, PRIMARY KEY (id, scope_key) ); diff --git a/src/storage/records.rs b/src/storage/records.rs index 5c77c53..5bf5a02 100644 --- a/src/storage/records.rs +++ b/src/storage/records.rs @@ -46,6 +46,7 @@ pub struct TodoRecord { pub priority: String, pub created_at: i64, pub updated_at: i64, + pub created_by_message_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 55895b9..e854174 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -235,6 +235,7 @@ impl SubAgentEmitter { priority: "medium".to_string(), created_at: now + idx as i64, updated_at: now, + created_by_message_id: Some(message.id.clone()), }) }) .collect(); diff --git a/src/tools/todo_read.rs b/src/tools/todo_read.rs index 018a6d3..3665124 100644 --- a/src/tools/todo_read.rs +++ b/src/tools/todo_read.rs @@ -118,6 +118,7 @@ impl Tool for TodoReadTool { id: r.id, content: r.content, status: r.status, + created_by_message_id: r.created_by_message_id, }) .collect(); @@ -225,6 +226,7 @@ mod tests { priority: "medium".to_string(), created_at: 1000, updated_at: 1000, + created_by_message_id: None, } } @@ -239,6 +241,7 @@ mod tests { id: "a1".to_string(), content: "任务A".to_string(), status: "pending".to_string(), + created_by_message_id: None, }], ); } @@ -314,6 +317,7 @@ mod tests { id: "m1".to_string(), content: "主会话任务".to_string(), status: "pending".to_string(), + created_by_message_id: None, }], ); } diff --git a/src/tools/todo_write.rs b/src/tools/todo_write.rs index fca31bf..ef896ab 100644 --- a/src/tools/todo_write.rs +++ b/src/tools/todo_write.rs @@ -45,6 +45,7 @@ pub(crate) struct TodoItem { pub id: String, pub content: String, pub status: String, + pub created_by_message_id: Option, } /// 工具完整返回 @@ -143,7 +144,10 @@ impl Tool for TodoWriteTool { None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")), }; - // 2. 解析入参 + // 2. 提取当前消息 ID(用于记录待办的创建来源) + let message_id = context.message_id.clone(); + + // 3. 解析入参 let todos_array = match args.get("todos").and_then(|v| v.as_array()) { Some(arr) => arr, None => return Ok(error_result("Missing required parameter: todos (must be an array)")), @@ -219,6 +223,7 @@ impl Tool for TodoWriteTool { id, content, status: new_status.as_str().to_string(), + created_by_message_id: message_id.clone(), }); } else if merge_mode { // merge 模式:id 不匹配,尝试 content fallback @@ -238,6 +243,7 @@ impl Tool for TodoWriteTool { id: old_item.id.clone(), content, status: new_status.as_str().to_string(), + created_by_message_id: message_id.clone(), }); } else { // 全新项 @@ -245,6 +251,7 @@ impl Tool for TodoWriteTool { id, content, status: new_status.as_str().to_string(), + created_by_message_id: message_id.clone(), }); } } else { @@ -253,6 +260,7 @@ impl Tool for TodoWriteTool { id, content, status: new_status.as_str().to_string(), + created_by_message_id: message_id.clone(), }); } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 28d43a2..acb471a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,7 @@ import { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' import { useWebSocket } from './hooks/useWebSocket' import { useChat } from './hooks/useChat' -import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol' +import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup, TodoItemSummary } from './types/protocol' function getInitialSettings(): GatewaySettings { return getGatewaySettings() @@ -54,6 +54,9 @@ function App() { setTodos, requestTodoList, requestSubAgentTodoList, + // 高亮消息 + highlightedMessageId, + setHighlightedMessageId, // 定时任务 schedulerJobs, sidebarTab, @@ -396,6 +399,17 @@ function App() { : requestTodoList() }, [subAgentView, requestTodoList, requestSubAgentTodoList]) + // 点击待办项后滚动到对应消息 + const handleTodoClick = useCallback((todo: TodoItemSummary) => { + // 直接使用后端返回的 created_by_message_id + if (todo.created_by_message_id) { + setHighlightedMessageId(todo.created_by_message_id) + } else { + // 如果消息 ID 不存在(旧数据),给出友好提示 + alert('该待办的完成记录无法定位,可能是历史数据') + } + }, [setHighlightedMessageId]) + const handleRefreshSchedulerJobs = useCallback(() => { const cmd = requestSchedulerJobList() handleCommand(cmd) @@ -694,11 +708,13 @@ function App() { onStop={handleStopExecution} showThinking={showThinking} viewKey={viewKey} + highlightedMessageId={highlightedMessageId} todoPanel={ } /> diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index c4b77bd..5e8da44 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -15,6 +15,8 @@ interface ChatContainerProps { todoPanel?: React.ReactNode /** 视图标识,用于保存/恢复滚动位置 */ viewKey?: string + /** 高亮的消息 ID */ + highlightedMessageId?: string | null } export function ChatContainer({ @@ -28,11 +30,12 @@ export function ChatContainer({ showThinking = true, todoPanel, viewKey, + highlightedMessageId, }: ChatContainerProps) { return (
- + {todoPanel}
+
diff --git a/web/src/components/Chat/MessageList.tsx b/web/src/components/Chat/MessageList.tsx index 6c60917..9bdb780 100644 --- a/web/src/components/Chat/MessageList.tsx +++ b/web/src/components/Chat/MessageList.tsx @@ -9,9 +9,11 @@ interface MessageListProps { showThinking?: boolean /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ viewKey?: string + /** 高亮的消息 ID,点击待办项后滚动并高亮显示 */ + highlightedMessageId?: string | null } -export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) { +export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) { const bottomRef = useRef(null) const containerRef = useRef(null) const isAtBottomRef = useRef(true) @@ -117,6 +119,28 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // ---- highlight and scroll to todo message ---- + + 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') + }, 2000) + }, [highlightedMessageId]) + // ---- empty state ---- if (messages.length === 0) { diff --git a/web/src/components/Panel/TodoPanel.tsx b/web/src/components/Panel/TodoPanel.tsx index dc7dd1c..68b5a9e 100644 --- a/web/src/components/Panel/TodoPanel.tsx +++ b/web/src/components/Panel/TodoPanel.tsx @@ -6,6 +6,7 @@ interface TodoPanelProps { todos: TodoItemSummary[] requestTodoList: () => Command sendCommand: (cmd: Command) => void + onTodoClick?: (todo: TodoItemSummary) => void } /* ── status config ────────────────────────────────────── */ @@ -64,7 +65,7 @@ function savePos(pos: { x: number; y: number }) { /* ── TodoPanel ────────────────────────────────────────── */ -export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) { +export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) { const [expanded, setExpanded] = useState(() => { try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false } }) @@ -249,12 +250,16 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
{items.map(item => ( -
+
+ ))}
diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 6dfae90..84ca431 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -106,6 +106,10 @@ interface UseChatReturn { requestTodoList: () => Command requestSubAgentTodoList: (subTaskId: string) => Command + // 高亮消息 ID(点击待办后滚动到对应消息) + highlightedMessageId: string | null + setHighlightedMessageId: Dispatch> + // 定时任务状态 schedulerJobs: SchedulerJobSummary[] sidebarTab: 'topics' | 'scheduler' @@ -156,6 +160,7 @@ export function useChat(): UseChatReturn { const [memories, setMemories] = useState([]) const [skills, setSkills] = useState([]) const [todos, setTodos] = useState([]) + const [highlightedMessageId, setHighlightedMessageId] = useState(null) const [schedulerJobs, setSchedulerJobs] = useState([]) const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [schedulerView, setSchedulerView] = useState(null) @@ -1101,6 +1106,8 @@ export function useChat(): UseChatReturn { setTodos, requestTodoList, requestSubAgentTodoList, + highlightedMessageId, + setHighlightedMessageId, schedulerJobs, sidebarTab, setSidebarTab, diff --git a/web/src/index.css b/web/src/index.css index 5f8339d..3e3b59c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -294,10 +294,25 @@ body { } } +@keyframes todo-highlight-pulse { + 0%, 100% { + background-color: transparent; + } + 50% { + background-color: rgba(0, 240, 255, 0.15); + } +} + .animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } .animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; } .animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; } +/* 待办点击高亮效果 */ +.todo-highlight { + animation: todo-highlight-pulse 1s ease-in-out 2; + border-radius: 8px; +} + /* 分组折叠内容展开/收起 */ .todo-group-body { overflow: hidden; diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index b7eca3f..bffc2c7 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -210,6 +210,7 @@ export interface TodoItemSummary { priority: string created_at: number updated_at: number + created_by_message_id?: string } export interface TodoList {