feat(todo): 添加待办项关联的创建消息ID并支持消息高亮

- 在待办相关数据结构和存储中新增 created_by_message_id 字段
- 记录待办项创建时对应的消息ID,支持追溯来源
- 在前端待办列表项增加点击事件,点击后滚动并高亮对应消息
- 在消息列表组件中实现高亮动画及自动滚动功能
- 更新相关工具、协议和数据库查询,确保新字段正确传递和存储
- 增加 CSS 动画实现待办对应消息的高亮闪烁效果
- 优化前端状态管理,支持设置与获取高亮消息ID
This commit is contained in:
oudecheng 2026-06-22 14:50:37 +08:00
parent 6a496ce212
commit 4efc8b51e7
16 changed files with 100 additions and 9 deletions

View File

@ -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();

View File

@ -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();

View File

@ -88,6 +88,7 @@ pub struct TodoItemSummary {
pub id: String,
pub content: String,
pub status: String,
pub created_by_message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -1517,7 +1517,7 @@ impl SessionStore {
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, 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)
);

View File

@ -46,6 +46,7 @@ pub struct TodoRecord {
pub priority: String,
pub created_at: i64,
pub updated_at: i64,
pub created_by_message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -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();

View File

@ -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,
}],
);
}

View File

@ -45,6 +45,7 @@ pub(crate) struct TodoItem {
pub id: String,
pub content: String,
pub status: String,
pub created_by_message_id: Option<String>,
}
/// 工具完整返回
@ -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(),
});
}
}

View File

@ -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={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/>
}
/>

View File

@ -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 (
<div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} />
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
{todoPanel}
</div>
<MessageInput

View File

@ -647,7 +647,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
}
return (
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
<div data-message-id={message.id} className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
>

View File

@ -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<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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) {

View File

@ -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
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
{items.map(item => (
<div key={item.id} className="group/item py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5">
<button
key={item.id}
onClick={() => onTodoClick?.(item)}
className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer"
>
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
{item.content}
</span>
</div>
</button>
))}
</div>
</div>

View File

@ -106,6 +106,10 @@ interface UseChatReturn {
requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
// 高亮消息 ID点击待办后滚动到对应消息
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
// 定时任务状态
schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler'
@ -156,6 +160,7 @@ export function useChat(): UseChatReturn {
const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
@ -1101,6 +1106,8 @@ export function useChat(): UseChatReturn {
setTodos,
requestTodoList,
requestSubAgentTodoList,
highlightedMessageId,
setHighlightedMessageId,
schedulerJobs,
sidebarTab,
setSidebarTab,

View File

@ -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;

View File

@ -210,6 +210,7 @@ export interface TodoItemSummary {
priority: string
created_at: number
updated_at: number
created_by_message_id?: string
}
export interface TodoList {