feat: 添加 Todo 面板,支持待办事项的展示与管理

This commit is contained in:
oudecheng 2026-06-12 15:17:49 +08:00
parent ce6dce81f4
commit 3f32079f92
4 changed files with 272 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
import { MemoryPanel } from './components/Panel/MemoryPanel' import { MemoryPanel } from './components/Panel/MemoryPanel'
import { SkillList } from './components/Panel/SkillList' import { SkillList } from './components/Panel/SkillList'
import { TodoPanel } from './components/Panel/TodoPanel'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { ChannelSelector } from './components/Header/ChannelSelector' import { ChannelSelector } from './components/Header/ChannelSelector'
import { SessionSelector } from './components/Header/SessionSelector' import { SessionSelector } from './components/Header/SessionSelector'
@ -42,6 +43,8 @@ function App() {
// 技能 // 技能
skills, skills,
requestSkillList, requestSkillList,
todos,
requestTodoList,
// 定时任务 // 定时任务
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
@ -677,6 +680,13 @@ function App() {
</div> </div>
)} )}
</div> </div>
{/* 悬浮 Todo 面板 */}
<TodoPanel
todos={todos}
requestTodoList={requestTodoList}
sendCommand={sendMemoryCommand}
/>
</div> </div>
) )
} }

View File

@ -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<string, StatusStyle> = {
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<string, string> = {
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<string, TodoItemSummary[]> {
const map = new Map<string, TodoItemSummary[]>()
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 (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400 shadow-[0_0_6px_#fbbf24]" />
</span>
)
}
/* ── 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<Set<string>>(() => new Set(COLLAPSED_DEFAULT))
const prevTodoIdsRef = useRef<Set<string>>(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 (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => sendCommand(requestTodoList())}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/70 backdrop-blur-sm border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-accent)] transition-all duration-300 shadow-lg text-xs"
title="刷新待办"
>
<ClipboardList className="h-3.5 w-3.5" />
<span></span>
</button>
</div>
)
}
// ── 折叠态 ──
if (!expanded) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/80 backdrop-blur-sm border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg group"
>
{inProgressCount > 0 ? <PulseDot /> : <ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)]" />}
<span className="text-sm font-medium text-[var(--text-secondary)] group-hover:text-[var(--accent-cyan)] transition-colors">
({totalCount})
</span>
<ChevronUp className="h-3.5 w-3.5 text-[var(--text-muted)]" />
</button>
</div>
)
}
// ── 展开态 ──
return (
<div className="fixed bottom-4 right-4 z-50 w-72 max-h-[60vh] flex flex-col rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/95 backdrop-blur-md shadow-2xl overflow-hidden transition-all duration-300">
{/* 标题栏 */}
<div className="shrink-0 flex items-center justify-between px-3 py-2.5 border-b border-[var(--border-color)]">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]" />
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="text-[11px] text-[var(--text-muted)] tabular-nums"> {totalCount} </span>
</div>
<button
onClick={() => setExpanded(false)}
className="p-1 rounded hover:bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
{/* 列表区 */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-2 py-2 space-y-2">
{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 (
<div key={status}>
{/* 分组标题 */}
<button
onClick={() => toggleGroup(status)}
className="flex items-center gap-1.5 w-full px-1 py-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
>
{isCollapsed ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<span className={style.text}>{style.icon}</span>
<span>{style.label}</span>
<span className="tabular-nums">({items.length})</span>
</button>
{/* 卡片列表 */}
{!isCollapsed && (
<div className="space-y-1">
{items.map(item => (
<div
key={item.id}
className={`rounded-lg border-l-2 ${style.border} border border-[var(--border-color)] bg-[var(--overlay-hover)]/50 px-2.5 py-1.5 transition-colors hover:border-[var(--border-accent)]`}
>
<div className="flex items-start gap-2">
<span className={`mt-0.5 shrink-0 ${priorityDot(item.priority)}`}>
<Circle className="h-2 w-2 fill-current" />
</span>
<div className="min-w-0 flex-1">
<p className={`text-[13px] leading-snug text-[var(--text-primary)] break-words ${
isTerminal ? 'line-through opacity-50' : ''
}`}>
{item.content}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] ${priorityDot(item.priority)}`}>
{item.priority}
</span>
<span className={`text-[10px] ${style.text}`}>
{style.icon} {style.label}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
{/* 底部刷新 */}
<div className="shrink-0 border-t border-[var(--border-color)] px-3 py-1.5">
<button
onClick={() => sendCommand(requestTodoList())}
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
>
</button>
</div>
</div>
)
}

View File

@ -20,6 +20,8 @@ import type {
MemoryList, MemoryList,
SkillSummary, SkillSummary,
SkillList, SkillList,
TodoItemSummary,
TodoList,
SchedulerJobList, SchedulerJobList,
SchedulerJobSummary, SchedulerJobSummary,
SchedulerJobSessionLookup, SchedulerJobSessionLookup,
@ -92,6 +94,10 @@ interface UseChatReturn {
skills: SkillSummary[] skills: SkillSummary[]
requestSkillList: () => Command requestSkillList: () => Command
// Todo 状态
todos: TodoItemSummary[]
requestTodoList: () => Command
// 定时任务状态 // 定时任务状态
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler' sidebarTab: 'topics' | 'scheduler'
@ -140,6 +146,7 @@ export function useChat(): UseChatReturn {
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null) const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
const [memories, setMemories] = useState<MemorySummary[]>([]) const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]) const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null) const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
@ -572,6 +579,11 @@ export function useChat(): UseChatReturn {
setSkills(msg.skills) setSkills(msg.skills)
break break
} }
case 'todo_list': {
const msg = message as TodoList
setTodos(msg.todos)
break
}
case 'channel_list': { case 'channel_list': {
const msg = message as ChannelList const msg = message as ChannelList
@ -750,6 +762,10 @@ export function useChat(): UseChatReturn {
return { type: 'list_skills' } return { type: 'list_skills' }
}, []) }, [])
const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' }
}, [])
// 定时任务方法 // 定时任务方法
const requestSchedulerJobList = useCallback((): Command => { const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' } return { type: 'list_scheduler_jobs' }
@ -838,6 +854,8 @@ export function useChat(): UseChatReturn {
deleteMemory, deleteMemory,
skills, skills,
requestSkillList, requestSkillList,
todos,
requestTodoList,
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
setSidebarTab, setSidebarTab,

View File

@ -201,6 +201,21 @@ export interface SkillList {
skills: SkillSummary[] 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 { export interface SchedulerJobSessionLookup {
channel: string channel: string
chat_id: string chat_id: string
@ -259,6 +274,7 @@ export type WsOutbound =
| SchedulerJobList | SchedulerJobList
| MemoryList | MemoryList
| SkillList | SkillList
| TodoList
| ExecutionCancelled | ExecutionCancelled
| Pong | Pong
@ -372,6 +388,10 @@ export interface ListSkillsCommand {
type: 'list_skills' type: 'list_skills'
} }
export interface ListTodosCommand {
type: 'list_todos'
}
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
@ -394,6 +414,7 @@ export type Command =
| UpdateMemoryCommand | UpdateMemoryCommand
| DeleteMemoryCommand | DeleteMemoryCommand
| ListSkillsCommand | ListSkillsCommand
| ListTodosCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types