diff --git a/web/src/App.tsx b/web/src/App.tsx index 67ae375..a9dfb9e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -663,6 +663,13 @@ function App() { onNavigateToSubAgent={handleNavigateToSubAgent} onStop={handleStopExecution} showThinking={showThinking} + todoPanel={ + + } /> @@ -731,13 +738,6 @@ function App() { )} - {/* 悬浮 Todo 面板 */} - - {/* 系统配置页面 */} {configPageOpen && ( setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} /> diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index b6cf5dd..c23aa2d 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -11,6 +11,8 @@ interface ChatContainerProps { onNavigateToSubAgent?: (taskId: string, description: string) => void onStop?: () => void showThinking?: boolean + /** 浮动待办面板,绝对定位在消息区域上方 */ + todoPanel?: React.ReactNode } export function ChatContainer({ @@ -22,11 +24,13 @@ export function ChatContainer({ onNavigateToSubAgent, onStop, showThinking = true, + todoPanel, }: ChatContainerProps) { return ( -
-
+
+
+ {todoPanel}
void } -/* ── status helpers ───────────────────────────────────── */ +/* ── status config ────────────────────────────────────── */ -interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string } +interface StatusCfg { label: string; color: string; dot: 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: '✕' }, +const STATUS: Record = { + in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' }, + pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' }, + completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' }, + cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' }, } -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: '?' } +function statusCfg(s: string): StatusCfg { + return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' } } -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() @@ -50,34 +39,49 @@ function groupTodos(todos: TodoItemSummary[]): Map { function PulseDot() { return ( - + - + ) } +/* ── 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 }: TodoPanelProps) { const [expanded, setExpanded] = useState(() => { - try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false } + try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false } }) - const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set(COLLAPSED_DEFAULT)) + 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-panel-open', String(expanded)) + localStorage.setItem('picobot-todo-expanded', String(expanded)) }, [expanded]) - // 当有新 todo 出现时自动展开 + // auto-expand on new items 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) - } + if (hasNewItems && todos.length > 0) setExpanded(true) prevTodoIdsRef.current = newIds }, [todos]) @@ -93,122 +97,157 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp }) }, []) - // ── 空状态 ── - if (totalCount === 0) { - return ( -
- -
- ) - } + 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 ( -
- {/* 标题栏 */} -
-
- - 待办事项 - 共 {totalCount} 项 +
+
+ {/* title bar (drag handle) */} +
+ + + 待办 + {totalCount} + {inProgressCount > 0 && } +
+ + +
- -
- {/* 列表区 */} -
- {GROUP_ORDER.map(status => { - const items = grouped.get(status) - if (!items || items.length === 0) return null + {/* list */} +
+ {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' + const cfg = statusCfg(status) + const isCollapsed = collapsedGroups.has(status) - return ( -
- {/* 分组标题 */} - + return ( +
+ - {/* 卡片列表 */} - {!isCollapsed && ( -
- {items.map(item => ( -
-
- - + {!isCollapsed && ( +
+ {items.map(item => ( +
+ + {item.content} -
-

- {item.content} -

-
-
- ))} -
- )} -
- ) - })} -
- - {/* 底部刷新 */} -
- + ))} +
+ )} +
+ ) + })} +
)