diff --git a/web/src/App.tsx b/web/src/App.tsx index 790beaf..97373b1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { TopicList } from './components/Sidebar/TopicList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { MemoryPanel } from './components/Panel/MemoryPanel' import { SkillList } from './components/Panel/SkillList' +import { TodoPanel } from './components/Panel/TodoPanel' import { ConnectionStatus } from './components/ConnectionStatus' import { ChannelSelector } from './components/Header/ChannelSelector' import { SessionSelector } from './components/Header/SessionSelector' @@ -42,6 +43,8 @@ function App() { // 技能 skills, requestSkillList, + todos, + requestTodoList, // 定时任务 schedulerJobs, sidebarTab, @@ -677,6 +680,13 @@ function App() { )} + + {/* 悬浮 Todo 面板 */} + ) } diff --git a/web/src/components/Panel/TodoPanel.tsx b/web/src/components/Panel/TodoPanel.tsx new file mode 100644 index 0000000..0be94f9 --- /dev/null +++ b/web/src/components/Panel/TodoPanel.tsx @@ -0,0 +1,223 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { ClipboardList, ChevronUp, ChevronDown, Circle } from 'lucide-react' +import type { TodoItemSummary, Command } from '../../types/protocol' + +interface TodoPanelProps { + todos: TodoItemSummary[] + requestTodoList: () => Command + sendCommand: (cmd: Command) => void +} + +/* ── status helpers ───────────────────────────────────── */ + +interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string } + +const STATUS: Record = { + in_progress: { label: '进行中', border: 'border-amber-400/60', dot: 'bg-amber-400 shadow-[0_0_6px_#fbbf24]', text: 'text-amber-300', icon: '●' }, + pending: { label: '待处理', border: 'border-slate-500/50', dot: 'bg-slate-500', text: 'text-slate-400', icon: '○' }, + completed: { label: '已完成', border: 'border-emerald-400/40', dot: 'bg-emerald-400', text: 'text-emerald-400', icon: '✓' }, + cancelled: { label: '已取消', border: 'border-red-400/40', dot: 'bg-red-400', text: 'text-red-400', icon: '✕' }, +} + +function statusStyle(s: string): StatusStyle { + return STATUS[s] ?? { label: s, border: 'border-[var(--border-color)]', dot: 'bg-[var(--text-muted)]', text: 'text-[var(--text-muted)]', icon: '?' } +} + +const PRIORITY: Record = { + high: 'text-rose-400', + medium: 'text-amber-400', + low: 'text-slate-400', +} + +function priorityDot(p: string) { return PRIORITY[p] ?? 'text-slate-400' } + +/* ── group helpers ────────────────────────────────────── */ + +const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled'] +const COLLAPSED_DEFAULT = new Set(['completed', 'cancelled']) + +function groupTodos(todos: TodoItemSummary[]): Map { + const map = new Map() + for (const t of todos) { + const list = map.get(t.status) ?? [] + list.push(t) + map.set(t.status, list) + } + return map +} + +/* ── pulse dot ────────────────────────────────────────── */ + +function PulseDot() { + return ( + + + + + ) +} + +/* ── TodoPanel ────────────────────────────────────────── */ + +export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) { + const [expanded, setExpanded] = useState(() => { + try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false } + }) + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set(COLLAPSED_DEFAULT)) + const prevTodoIdsRef = useRef>(new Set()) + + // 持久化展开状态 + useEffect(() => { + localStorage.setItem('picobot-todo-panel-open', String(expanded)) + }, [expanded]) + + // 当有新 todo 出现时自动展开 + useEffect(() => { + const newIds = new Set(todos.map(t => t.id)) + const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) + if (hasNewItems && todos.length > 0) { + setExpanded(true) + } + prevTodoIdsRef.current = newIds + }, [todos]) + + const grouped = groupTodos(todos) + const inProgressCount = grouped.get('in_progress')?.length ?? 0 + const totalCount = todos.length + + const toggleGroup = useCallback((status: string) => { + setCollapsedGroups(prev => { + const next = new Set(prev) + if (next.has(status)) next.delete(status); else next.add(status) + return next + }) + }, []) + + // ── 空状态 ── + if (totalCount === 0) { + return ( +
+ +
+ ) + } + + // ── 折叠态 ── + if (!expanded) { + return ( +
+ +
+ ) + } + + // ── 展开态 ── + return ( +
+ {/* 标题栏 */} +
+
+ + 待办事项 + 共 {totalCount} 项 +
+ +
+ + {/* 列表区 */} +
+ {GROUP_ORDER.map(status => { + const items = grouped.get(status) + if (!items || items.length === 0) return null + + const style = statusStyle(status) + const isCollapsed = collapsedGroups.has(status) + const isTerminal = status === 'completed' || status === 'cancelled' + + return ( +
+ {/* 分组标题 */} + + + {/* 卡片列表 */} + {!isCollapsed && ( +
+ {items.map(item => ( +
+
+ + + +
+

+ {item.content} +

+
+ + {item.priority} + + + {style.icon} {style.label} + +
+
+
+
+ ))} +
+ )} +
+ ) + })} +
+ + {/* 底部刷新 */} +
+ +
+
+ ) +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 9fc3033..6c51562 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -20,6 +20,8 @@ import type { MemoryList, SkillSummary, SkillList, + TodoItemSummary, + TodoList, SchedulerJobList, SchedulerJobSummary, SchedulerJobSessionLookup, @@ -92,6 +94,10 @@ interface UseChatReturn { skills: SkillSummary[] requestSkillList: () => Command + // Todo 状态 + todos: TodoItemSummary[] + requestTodoList: () => Command + // 定时任务状态 schedulerJobs: SchedulerJobSummary[] sidebarTab: 'topics' | 'scheduler' @@ -140,6 +146,7 @@ export function useChat(): UseChatReturn { const [subAgentView, setSubAgentView] = useState(null) const [memories, setMemories] = useState([]) const [skills, setSkills] = useState([]) + const [todos, setTodos] = useState([]) const [schedulerJobs, setSchedulerJobs] = useState([]) const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [schedulerView, setSchedulerView] = useState(null) @@ -572,6 +579,11 @@ export function useChat(): UseChatReturn { setSkills(msg.skills) break } + case 'todo_list': { + const msg = message as TodoList + setTodos(msg.todos) + break + } case 'channel_list': { const msg = message as ChannelList @@ -750,6 +762,10 @@ export function useChat(): UseChatReturn { return { type: 'list_skills' } }, []) + const requestTodoList = useCallback((): Command => { + return { type: 'list_todos' } + }, []) + // 定时任务方法 const requestSchedulerJobList = useCallback((): Command => { return { type: 'list_scheduler_jobs' } @@ -838,6 +854,8 @@ export function useChat(): UseChatReturn { deleteMemory, skills, requestSkillList, + todos, + requestTodoList, schedulerJobs, sidebarTab, setSidebarTab, diff --git a/web/src/types/protocol.ts b/web/src/types/protocol.ts index 3540f6d..1b6b708 100644 --- a/web/src/types/protocol.ts +++ b/web/src/types/protocol.ts @@ -201,6 +201,21 @@ export interface SkillList { skills: SkillSummary[] } +export interface TodoItemSummary { + id: string + content: string + status: string + priority: string + created_at: number + updated_at: number +} + +export interface TodoList { + type: 'todo_list' + todos: TodoItemSummary[] + scope_key: string +} + export interface SchedulerJobSessionLookup { channel: string chat_id: string @@ -259,6 +274,7 @@ export type WsOutbound = | SchedulerJobList | MemoryList | SkillList + | TodoList | ExecutionCancelled | Pong @@ -372,6 +388,10 @@ export interface ListSkillsCommand { type: 'list_skills' } +export interface ListTodosCommand { + type: 'list_todos' +} + export type Command = | CreateSessionCommand | ListSessionsCommand @@ -394,6 +414,7 @@ export type Command = | UpdateMemoryCommand | DeleteMemoryCommand | ListSkillsCommand + | ListTodosCommand // ============================================================================ // UI Types