Compare commits

..

3 Commits

Author SHA1 Message Date
oudecheng
bf724b133c feat(chat): 支持子智能体栈和面包屑导航功能
- 引入子智能体栈结构以支持多层子智能体导航
- 替换原有单一子智能体视图为子智能体栈管理消息和状态
- 添加navigateToSubAgentLevel方法,支持通过面包屑快速跳转子智能体层级
- 调整子智能体面包屑条UI,显示完整层级的描述、类型和状态
- 优化子智能体消息流和状态更新逻辑,保持栈顶视图同步
- 更新退出子智能体视图逻辑以支持栈弹出操作
- 添加主会话入口和各层子智能体的切换按钮及状态颜色显示
2026-06-18 14:53:05 +08:00
oudecheng
175e7fc01b refactor(subagent): 支持带类型的子智能体标题格式
- 将子智能体标题解析升级为支持 "Subagent [type]: description" 格式
- 兼容旧格式 "Subagent: description",默认类型为 "general"
- 修改子智能体会话标题生成,包含类型信息
- 优化记录解析和会话创建逻辑,提高子智能体信息表达准确性
2026-06-18 14:27:38 +08:00
oudecheng
6962ea2eb1 feat(todo): 优化悬浮待办面板功能
- 将悬浮待办面板集成到聊天组件上方,避免重复渲染
- 调整状态样式配置,简化颜色和样式管理
- 实现待办面板位置拖拽功能,支持位置持久化保存
- 优化折叠与展开交互,改进分组标题和列表项显示样式
- 设计迷你和完整两种面板展现形态,提升界面灵活性
- 添加刷新按钮及自动展开待办新条目功能
- 精简和改进待办项展示,提高内容可读性和界面美观度
2026-06-18 14:25:41 +08:00
6 changed files with 333 additions and 214 deletions

View File

@ -144,12 +144,8 @@ fn reconstruct_task_from_db(
}) })
.unwrap_or_default(); .unwrap_or_default();
// Extract description from title: "Subagent: {description}" // Extract subagent_type and description from title
let description = record let (subagent_type, description) = parse_subagent_title(&record.title);
.title
.strip_prefix("Subagent: ")
.unwrap_or(&record.title)
.to_string();
let now = record.updated_at; let now = record.updated_at;
@ -161,7 +157,7 @@ fn reconstruct_task_from_db(
parent_chat_id: record.chat_id.clone(), parent_chat_id: record.chat_id.clone(),
parent_channel_name: record.channel_name.clone(), parent_channel_name: record.channel_name.clone(),
description, description,
subagent_type: "general".to_string(), subagent_type,
state: TaskSessionState::Completed, state: TaskSessionState::Completed,
created_at: record.created_at, created_at: record.created_at,
updated_at: now, updated_at: now,
@ -169,3 +165,18 @@ fn reconstruct_task_from_db(
error: None, error: None,
})) }))
} }
/// Parse subagent title to extract type and description.
/// New format: "Subagent [type]: description"
/// Legacy format: "Subagent: description" (defaults to "general")
fn parse_subagent_title(title: &str) -> (String, String) {
if let Some(rest) = title.strip_prefix("Subagent [") {
if let Some(bracket_pos) = rest.find("]: ") {
let agent_type = rest[..bracket_pos].to_string();
let desc = rest[bracket_pos + 3..].to_string();
return (agent_type, desc);
}
}
let desc = title.strip_prefix("Subagent: ").unwrap_or(title).to_string();
("general".to_string(), desc)
}

View File

@ -509,7 +509,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
); );
// 4. 在 sessions 表中创建子智能体会话(确保外键约束满足) // 4. 在 sessions 表中创建子智能体会话(确保外键约束满足)
let session_title = format!("Subagent: {}", task.description); let session_title = format!("Subagent [{}]: {}", session.subagent_type, task.description);
if let Err(e) = self.conversation_repository.ensure_session( if let Err(e) = self.conversation_repository.ensure_session(
&session.session_id, &session.session_id,
&session.parent_channel_name, &session.parent_channel_name,
@ -638,7 +638,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} }
// 3. 确保 sessions 表中存在子智能体会话记录 // 3. 确保 sessions 表中存在子智能体会话记录
let session_title = format!("Subagent: {}", session.description); let session_title = format!("Subagent [{}]: {}", session.subagent_type, session.description);
if let Err(e) = self.conversation_repository.ensure_session( if let Err(e) = self.conversation_repository.ensure_session(
&session.session_id, &session.session_id,
&session.parent_channel_name, &session.parent_channel_name,

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon } from 'lucide-react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
@ -40,6 +40,7 @@ function App() {
isReadOnly, isReadOnly,
// 子智能体视图 // 子智能体视图
subAgentView, subAgentView,
subAgentStack,
// 记忆 // 记忆
memories, memories,
requestMemoryList, requestMemoryList,
@ -85,6 +86,7 @@ function App() {
topicRefreshTrigger, topicRefreshTrigger,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
navigateToSubAgentLevel,
handleStop, handleStop,
} = useChat() } = useChat()
@ -607,45 +609,66 @@ function App() {
</div> </div>
</div> </div>
)} )}
{/* Sub-agent back bar */} {/* Sub-agent breadcrumb bar */}
{subAgentView && ( {subAgentView && (
<div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-4"> <div className="shrink-0 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/80 px-4 py-2 flex items-center gap-2">
<button <button
onClick={handleExitSubAgentView} onClick={handleExitSubAgentView}
className="flex items-center gap-1.5 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors" className="flex items-center gap-1 text-sm text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 transition-colors shrink-0"
title="返回上一级"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span></span>
</button> </button>
<div className="h-4 w-px bg-[var(--divider-color)]" /> {/* Breadcrumb: 主会话 */}
<div className="flex items-center gap-1.5 text-sm text-[var(--text-secondary)]"> <button
<Bot className="h-4 w-4 text-violet-400" /> onClick={() => navigateToSubAgentLevel(-1)}
<span className="text-[var(--text-muted)]">:</span> className="flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors shrink-0"
<span className="text-[var(--text-primary)] font-medium">{subAgentView.description}</span> title="返回主会话"
</div> >
<div className="h-4 w-px bg-[var(--divider-color)]" /> <Bot className="h-3.5 w-3.5" />
<div className="flex items-center gap-1.5 text-sm"> <span></span>
<span className="text-[var(--text-muted)]">:</span> </button>
<span className="text-[var(--text-secondary)]">{subAgentView.subagentType || '...'}</span> {/* Breadcrumb: each sub-agent level */}
</div> {subAgentStack.map((level, idx) => {
<div className="h-4 w-px bg-[var(--divider-color)]" /> const isLast = idx === subAgentStack.length - 1
<div className="flex items-center gap-1.5 text-sm"> const statusText =
<span className="text-[var(--text-muted)]">:</span> level.status === 'completed' ? '已完成' :
<span className={`font-medium ${ level.status === 'failed' ? '失败' :
subAgentView.status === 'completed' ? 'text-emerald-400' : level.status === 'timeout' ? '超时' :
subAgentView.status === 'failed' ? 'text-red-400' : level.status === 'running' ? '执行中' :
subAgentView.status === 'timeout' ? 'text-amber-400' : level.status === 'loading' ? '加载中...' :
subAgentView.status === 'running' ? 'text-amber-400' : level.status
const statusColor =
level.status === 'completed' ? 'text-emerald-400' :
level.status === 'failed' ? 'text-red-400' :
level.status === 'timeout' ? 'text-amber-400' :
level.status === 'running' ? 'text-amber-400' :
'text-[var(--text-secondary)]' 'text-[var(--text-secondary)]'
}`}> return (
{subAgentView.status === 'completed' ? '已完成' : <div key={level.taskId} className="flex items-center gap-2 min-w-0">
subAgentView.status === 'failed' ? '失败' : <ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)] shrink-0" />
subAgentView.status === 'timeout' ? '超时' : {isLast ? (
subAgentView.status === 'running' ? '执行中' : <div className="flex items-center gap-2 text-sm min-w-0">
subAgentView.status === 'loading' ? '加载中...' : <span className="text-[var(--text-primary)] font-medium truncate">{level.description}</span>
subAgentView.status} {level.subagentType && (
</span> <span className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] px-1.5 py-0.5 rounded shrink-0">{level.subagentType}</span>
)}
<span className={`text-xs font-medium shrink-0 ${statusColor}`}>{statusText}</span>
</div> </div>
) : (
<button
onClick={() => navigateToSubAgentLevel(idx)}
className="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors min-w-0"
>
<span className="truncate">{level.description}</span>
{level.subagentType && (
<span className="text-xs bg-[var(--overlay-dim)] px-1.5 py-0.5 rounded shrink-0">{level.subagentType}</span>
)}
</button>
)}
</div>
)
})}
</div> </div>
)} )}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
@ -663,6 +686,13 @@ function App() {
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
todoPanel={
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
/>
}
/> />
</div> </div>
</div> </div>
@ -731,13 +761,6 @@ function App() {
)} )}
</div> </div>
{/* 悬浮 Todo 面板 */}
<TodoPanel
todos={todos}
requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand}
/>
{/* 系统配置页面 */} {/* 系统配置页面 */}
{configPageOpen && ( {configPageOpen && (
<ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} /> <ConfigPage onClose={() => setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} />

View File

@ -11,6 +11,8 @@ interface ChatContainerProps {
onNavigateToSubAgent?: (taskId: string, description: string) => void onNavigateToSubAgent?: (taskId: string, description: string) => void
onStop?: () => void onStop?: () => void
showThinking?: boolean showThinking?: boolean
/** 浮动待办面板,绝对定位在消息区域上方 */
todoPanel?: React.ReactNode
} }
export function ChatContainer({ export function ChatContainer({
@ -22,11 +24,13 @@ export function ChatContainer({
onNavigateToSubAgent, onNavigateToSubAgent,
onStop, onStop,
showThinking = true, showThinking = true,
todoPanel,
}: ChatContainerProps) { }: ChatContainerProps) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} /> <MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} />
{todoPanel}
</div> </div>
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}

View File

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { ClipboardList, ChevronUp, ChevronDown, Circle } from 'lucide-react' import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react'
import type { TodoItemSummary, Command } from '../../types/protocol' import type { TodoItemSummary, Command } from '../../types/protocol'
interface TodoPanelProps { interface TodoPanelProps {
@ -8,33 +8,22 @@ interface TodoPanelProps {
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => 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<string, StatusStyle> = { const STATUS: Record<string, StatusCfg> = {
in_progress: { label: '进行中', border: 'border-amber-400/60', dot: 'bg-amber-400 shadow-[0_0_6px_#fbbf24]', text: 'text-amber-300', icon: '●' }, in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
pending: { label: '待处理', border: 'border-slate-500/50', dot: 'bg-slate-500', text: 'text-slate-400', icon: '○' }, pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', border: 'border-emerald-400/40', dot: 'bg-emerald-400', text: 'text-emerald-400', icon: '✓' }, completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
cancelled: { label: '已取消', border: 'border-red-400/40', dot: 'bg-red-400', text: 'text-red-400', icon: '✕' }, cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
} }
function statusStyle(s: string): StatusStyle { function statusCfg(s: string): StatusCfg {
return STATUS[s] ?? { label: s, border: 'border-[var(--border-color)]', dot: 'bg-[var(--text-muted)]', text: 'text-[var(--text-muted)]', icon: '?' } return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' }
} }
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 GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled']
const COLLAPSED_DEFAULT = new Set(['completed', 'cancelled'])
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> { function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
const map = new Map<string, TodoItemSummary[]>() const map = new Map<string, TodoItemSummary[]>()
@ -50,34 +39,49 @@ function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
function PulseDot() { function PulseDot() {
return ( return (
<span className="relative flex h-2 w-2"> <span className="relative flex h-1.5 w-1.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" /> <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 className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
</span> </span>
) )
} }
/* ── 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 ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) { export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
const [expanded, setExpanded] = useState(() => { 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<Set<string>>(() => new Set(COLLAPSED_DEFAULT)) const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled']))
const [pos, setPos] = useState(loadPos)
const prevTodoIdsRef = useRef<Set<string>>(new Set()) const prevTodoIdsRef = useRef<Set<string>>(new Set())
const dragRef = useRef<{ startX: number; startY: number; startPos: { x: number; y: number }; moved: boolean } | null>(null)
// 持久化展开状态
useEffect(() => { useEffect(() => {
localStorage.setItem('picobot-todo-panel-open', String(expanded)) localStorage.setItem('picobot-todo-expanded', String(expanded))
}, [expanded]) }, [expanded])
// 当有新 todo 出现时自动展开 // auto-expand on new items
useEffect(() => { useEffect(() => {
const newIds = new Set(todos.map(t => t.id)) const newIds = new Set(todos.map(t => t.id))
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems && todos.length > 0) { if (hasNewItems && todos.length > 0) setExpanded(true)
setExpanded(true)
}
prevTodoIdsRef.current = newIds prevTodoIdsRef.current = newIds
}, [todos]) }, [todos])
@ -93,105 +97,149 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
}) })
}, []) }, [])
// ── 空状态 ── const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
if (totalCount === 0) {
return ( /* ── drag handling ──────────────────────────────────── */
<div className="fixed bottom-4 right-4 z-50">
<button const handleDragStart = useCallback((e: React.MouseEvent) => {
onClick={() => sendCommand(requestTodoList())} if ((e.target as HTMLElement).closest('button')) return
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" e.preventDefault()
title="刷新待办"
> const startX = e.clientX
<ClipboardList className="h-3.5 w-3.5" /> const startY = e.clientY
<span></span> const startPos = { ...pos }
</button> dragRef.current = { startX, startY, startPos, moved: false }
</div>
) 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) { if (!expanded) {
return ( return (
<div className="fixed bottom-4 right-4 z-50"> <div
<button className="absolute z-30"
onClick={() => setExpanded(true)} style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
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)]" />} <div
<span className="text-sm font-medium text-[var(--text-secondary)] group-hover:text-[var(--accent-cyan)] transition-colors"> className="relative cursor-grab active:cursor-grabbing select-none"
({totalCount}) onMouseDown={handleDragStart}
>
<button
onClick={() => { if (totalCount > 0) setExpanded(true); else handleRefresh() }}
className="relative w-12 h-12 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-md border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/40 shadow-lg hover:shadow-[0_0_20px_var(--shadow-glow-sm)] transition-all duration-300 group"
title="待办"
>
<div className="flex flex-col items-center justify-center">
<ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
<span className="text-[8px] font-bold text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors mt-0.5">
ToDo
</span> </span>
<ChevronUp className="h-3.5 w-3.5 text-[var(--text-muted)]" /> </div>
{totalCount > 0 && (
<span className={`absolute -top-1 -right-1 min-w-[18px] h-[18px] rounded-full flex items-center justify-center text-[10px] font-bold shadow-md ${
inProgressCount > 0
? 'bg-amber-400 text-black'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'
}`}>
{totalCount}
</span>
)}
{inProgressCount > 0 && (
<span className="absolute inset-0 rounded-full border-2 border-amber-400/30 animate-ping" />
)}
</button> </button>
</div> </div>
</div>
) )
} }
// ── 展开态 ── /* ── expanded: full card ────────────────────────────── */
return ( 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="absolute z-30"
<div className="shrink-0 flex items-center justify-between px-3 py-2.5 border-b border-[var(--border-color)]"> style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
<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" /> <div className="w-72 max-h-[50vh] flex flex-col rounded-xl bg-[var(--bg-tertiary)]/95 backdrop-blur-md border border-[var(--border-color)] shadow-2xl overflow-hidden">
{/* title bar (drag handle) */}
<div
className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-[var(--border-color)]/50 cursor-grab active:cursor-grabbing select-none"
onMouseDown={handleDragStart}
>
<span className="text-[var(--text-muted)]/30 text-[10px] leading-none select-none"></span>
<ClipboardList className="h-3 w-3 text-[var(--accent-cyan)]" />
<span className="text-[11px] font-semibold text-[var(--text-secondary)]"></span>
<span className="text-[10px] text-[var(--text-muted)]/70 tabular-nums">{totalCount}</span>
{inProgressCount > 0 && <PulseDot />}
<div className="ml-auto flex items-center gap-0.5">
<button onClick={handleRefresh} className="p-1 rounded text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] transition-colors" title="刷新">
<RefreshCw className="h-3 w-3" />
</button>
<button onClick={() => setExpanded(false)} className="p-1 rounded text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] transition-colors" title="缩小">
<ChevronDown className="h-3 w-3" />
</button> </button>
</div> </div>
</div>
{/* 列表区 */} {/* list */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-2 py-2 space-y-2"> <div className="flex-1 overflow-y-auto scrollbar-hide px-3 py-2">
{GROUP_ORDER.map(status => { {GROUP_ORDER.map(status => {
const items = grouped.get(status) const items = grouped.get(status)
if (!items || items.length === 0) return null if (!items || items.length === 0) return null
const style = statusStyle(status) const cfg = statusCfg(status)
const isCollapsed = collapsedGroups.has(status) const isCollapsed = collapsedGroups.has(status)
const isTerminal = status === 'completed' || status === 'cancelled'
return ( return (
<div key={status}> <div key={status} className="mt-1 first:mt-0">
{/* 分组标题 */}
<button <button
onClick={() => toggleGroup(status)} 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" className="flex items-center gap-1.5 w-full py-0.5 group"
> >
{isCollapsed ? ( <span className={`h-1.5 w-1.5 rounded-full ${cfg.dot} shrink-0`} />
<ChevronUp className="h-3 w-3" /> <span className={`text-[10px] font-medium ${cfg.color}`}>{cfg.label}</span>
) : ( <span className={`text-[10px] ${cfg.color} opacity-60 tabular-nums`}>{items.length}</span>
<ChevronDown className="h-3 w-3" /> <span className="ml-auto text-[var(--text-muted)]/40">
)} {isCollapsed
<span className={style.text}>{style.icon}</span> ? <ChevronDown className="h-2.5 w-2.5 rotate-[-90deg]" />
<span>{style.label}</span> : <ChevronDown className="h-2.5 w-2.5" />
<span className="tabular-nums">({items.length})</span> }
</span>
</button> </button>
{/* 卡片列表 */}
{!isCollapsed && ( {!isCollapsed && (
<div className="space-y-1"> <div className="ml-3 border-l border-[var(--border-color)]/30 pl-2 mt-0.5 space-y-px">
{items.map(item => ( {items.map(item => (
<div <div key={item.id} className="py-[3px]">
key={item.id} <span className="text-[12px] leading-snug text-[var(--text-primary)]/90 break-words">
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} {item.content}
</p> </span>
</div>
</div>
</div> </div>
))} ))}
</div> </div>
@ -200,15 +248,6 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
) )
})} })}
</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>
</div> </div>
) )

View File

@ -59,8 +59,9 @@ interface UseChatReturn {
// 是否只读 // 是否只读
isReadOnly: boolean isReadOnly: boolean
// 子智能体视图 // 子智能体视图(栈结构,支持面包屑导航)
subAgentView: SubAgentView | null subAgentView: SubAgentView | null
subAgentStack: SubAgentView[]
// 方法 // 方法
handleMessage: (content: string, attachments?: Attachment[]) => void handleMessage: (content: string, attachments?: Attachment[]) => void
@ -86,6 +87,7 @@ interface UseChatReturn {
// 子智能体导航方法 // 子智能体导航方法
enterSubAgentView: (taskId: string, description: string) => Command enterSubAgentView: (taskId: string, description: string) => Command
exitSubAgentView: () => void exitSubAgentView: () => void
navigateToSubAgentLevel: (index: number) => void
// 记忆状态 // 记忆状态
memories: MemorySummary[] memories: MemorySummary[]
@ -149,7 +151,8 @@ export function useChat(): UseChatReturn {
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0) const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
const [sessions, setSessions] = useState<SessionSummary[]>([]) const [sessions, setSessions] = useState<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null) const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null) const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
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 [todos, setTodos] = useState<TodoItemSummary[]>([])
@ -302,11 +305,12 @@ export function useChat(): UseChatReturn {
// stream_delta: accumulate into existing message by ID, or create new // stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta const msg = message as StreamDelta
setSubAgentView((prev) => { setSubAgentStack((prev) => {
if (!prev) return prev if (prev.length === 0) return prev
const existingIdx = prev.messages.findIndex(m => m.id === msg.id && m.type === 'message') const top = prev[prev.length - 1]
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...prev.messages] const updated = [...top.messages]
const existing = updated[existingIdx] const existing = updated[existingIdx]
updated[existingIdx] = { updated[existingIdx] = {
...existing, ...existing,
@ -315,10 +319,15 @@ export function useChat(): UseChatReturn {
? (existing.reasoningContent || '') + msg.reasoning_delta ? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent, : existing.reasoningContent,
} }
return { ...prev, messages: updated } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
} }
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message)
return chatMsg ? { ...prev, messages: [...prev.messages, chatMsg] } : prev if (!chatMsg) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
}) })
return return
} }
@ -327,17 +336,22 @@ export function useChat(): UseChatReturn {
// Other messages: assistant_response replaces streamed message by ID // Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message)
if (chatMsg) { if (chatMsg) {
setSubAgentView((prev) => { setSubAgentStack((prev) => {
if (!prev) return prev if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
const existingIdx = prev.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...prev.messages] const updated = [...top.messages]
updated[existingIdx] = chatMsg updated[existingIdx] = chatMsg
return { ...prev, messages: updated } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
} }
} }
return { ...prev, messages: [...prev.messages, chatMsg] } const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
}) })
} }
} }
@ -366,16 +380,18 @@ export function useChat(): UseChatReturn {
if (currentSubAgentView) { if (currentSubAgentView) {
if (message.type === 'task_messages_loaded') { if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded const msg = message as TaskMessagesLoaded
setSubAgentView((prev) => setSubAgentStack((prev) => {
prev if (prev.length === 0) return prev
? { const top = prev[prev.length - 1]
...prev, const newStack = [...prev]
newStack[newStack.length - 1] = {
...top,
subagentType: msg.subagent_type, subagentType: msg.subagent_type,
status: msg.status, status: msg.status,
summary: msg.summary, summary: msg.summary,
} }
: prev return newStack
) })
return return
} }
@ -756,7 +772,7 @@ export function useChat(): UseChatReturn {
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId) setSelectedTopic(topicId)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
}, []) }, [])
const createTopic = useCallback((title?: string): Command => { const createTopic = useCallback((title?: string): Command => {
@ -802,7 +818,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
setIsLoading(true) setIsLoading(true)
}, [selectedChannel]) }, [selectedChannel])
@ -812,7 +828,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null) setSubAgentStack([])
setIsLoading(true) setIsLoading(true)
}, [selectedSessionId]) }, [selectedSessionId])
@ -849,9 +865,12 @@ export function useChat(): UseChatReturn {
status: 'loading', status: 'loading',
messages: [], messages: [],
} }
setSubAgentStack((prev) => {
const newStack = [...prev, newView]
// Sync ref immediately so WebSocket response routing works correctly // Sync ref immediately so WebSocket response routing works correctly
subAgentViewRef.current = newView subAgentViewRef.current = newView
setSubAgentView(newView) return newStack
})
return { return {
type: 'load_task_messages', type: 'load_task_messages',
task_id: taskId, task_id: taskId,
@ -859,8 +878,29 @@ export function useChat(): UseChatReturn {
}, []) }, [])
const exitSubAgentView = useCallback(() => { const exitSubAgentView = useCallback(() => {
setSubAgentStack((prev) => {
if (prev.length <= 1) {
subAgentViewRef.current = null subAgentViewRef.current = null
setSubAgentView(null) return []
}
const newStack = prev.slice(0, -1)
subAgentViewRef.current = newStack[newStack.length - 1]
return newStack
})
}, [])
const navigateToSubAgentLevel = useCallback((index: number) => {
setSubAgentStack((prev) => {
if (index < 0) {
// -1 means go back to main session (clear all)
subAgentViewRef.current = null
return []
}
if (index >= prev.length) return prev
const newStack = prev.slice(0, index + 1)
subAgentViewRef.current = newStack.length > 0 ? newStack[newStack.length - 1] : null
return newStack
})
}, []) }, [])
// 记忆方法 // 记忆方法
@ -957,6 +997,7 @@ export function useChat(): UseChatReturn {
channels, channels,
selectedChannel, selectedChannel,
subAgentView, subAgentView,
subAgentStack,
handleMessage, handleMessage,
handleCommand, handleCommand,
clearMessages, clearMessages,
@ -974,6 +1015,7 @@ export function useChat(): UseChatReturn {
selectSession, selectSession,
enterSubAgentView, enterSubAgentView,
exitSubAgentView, exitSubAgentView,
navigateToSubAgentLevel,
memories, memories,
requestMemoryList, requestMemoryList,
createMemory, createMemory,