Compare commits

..

No commits in common. "edc975e6d013fc3fe777299f62f8c38e7a16ebca" and "bf724b133cf4f9002d72e0b8e3f89c7fef63edec" have entirely different histories.

14 changed files with 139 additions and 289 deletions

View File

@ -41,10 +41,10 @@ impl CommandHandler for ListTodosCommandHandler {
_ => None,
};
// 子代理scope_key = task_id全局唯一与 todo_write 保持一致)
// 子代理scope_key = sub:{parent_session_id}:{task_id}
// 主代理scope_key = topic_id.unwrap_or(session_id)
let scope_key = if let Some(tid) = task_id.as_deref() {
tid.to_string()
let scope_key = if let (Some(tid), Some(parent_sid)) = (task_id.as_deref(), ctx.session_id.as_deref()) {
format!("sub:{}:{}", parent_sid, tid)
} else {
ctx.topic_id
.as_deref()

View File

@ -5,17 +5,15 @@ use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::cancel_manager::CancelManager;
use crate::gateway::session::SessionManager;
/// 处理 StopExecution 命令:按话题取消当前正在执行的 Agent。
pub struct StopExecutionCommandHandler {
cancel_manager: CancelManager,
session_manager: SessionManager,
}
impl StopExecutionCommandHandler {
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
Self { cancel_manager, session_manager }
pub fn new(cancel_manager: CancelManager) -> Self {
Self { cancel_manager }
}
}
@ -38,45 +36,15 @@ impl CommandHandler for StopExecutionCommandHandler {
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
// 优先使用 ctx.topic_id如果没有则从 session_manager 获取真实的 topic_id
let topic_id = match ctx.topic_id.as_deref() {
Some(id) => {
tracing::info!(
channel = %ctx.channel_name,
chat_id = ?ctx.chat_id,
topic_id = %id,
source = "ctx",
"Stop execution command received"
);
id.to_string()
}
Some(id) => id,
None => {
// 从 SessionManager 获取真实的 current topic
let chat_id = ctx.chat_id.as_deref().unwrap_or("");
match self.session_manager.get_current_topic(&ctx.channel_name, chat_id).await {
Ok(Some(id)) => {
tracing::info!(
channel = %ctx.channel_name,
chat_id = %chat_id,
topic_id = %id,
source = "session_manager",
"Stop execution command received (resolved from session)"
);
id
}
Ok(None) => {
return Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
}
Err(e) => {
return Ok(CommandResponse::error(ctx.request_id,
CommandError::new("QUERY_TOPIC_ERROR", e.to_string())));
}
}
return Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
}
};
let cancelled = self.cancel_manager.cancel_by_topic(&topic_id).await;
let cancelled = self.cancel_manager.cancel_by_topic(topic_id).await;
if cancelled {
Ok(CommandResponse::success(ctx.request_id)

View File

@ -113,7 +113,6 @@ impl InboundProcessor {
// 注册 stop_execution 处理器
command_router.register(Box::new(StopExecutionCommandHandler::new(
cancel_manager.clone(),
session_manager.clone(),
)));
Self {

View File

@ -432,7 +432,6 @@ async fn handle_inbound(
// 注册 stop_execution 处理器
router.register(Box::new(StopExecutionCommandHandler::new(
state.cancel_manager.clone(),
state.session_manager.clone(),
)));
// 构建命令上下文

View File

@ -110,8 +110,7 @@ struct SubAgentEmitter {
chat_id: String,
metadata: HashMap<String, String>,
store: Arc<SessionStore>,
/// 子/孙智能体自身的 task_id用于持久化时作为 scope_key
task_id: String,
sub_session_id: String,
stream_message_id: std::sync::Mutex<Option<String>>,
}
@ -160,7 +159,7 @@ impl EmittedMessageHandler for SubAgentEmitter {
}
}
// 拦截 todo_write 结果:持久化到 SQLite子代理用 task_id 作为 scope_key与 list_todos 保持一致
// 拦截 todo_write 结果:持久化到 SQLite子代理用 session_id 作为 scope_key
if message.tool_name.as_deref() == Some("todo_write") {
self.persist_todo_write_result(&message);
}
@ -213,7 +212,7 @@ impl SubAgentEmitter {
return;
};
let scope_key = &self.task_id;
let scope_key = &self.sub_session_id;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -367,7 +366,7 @@ impl DefaultSubAgentRuntime {
chat_id: session.parent_chat_id.clone(),
metadata,
store: self.store.clone(),
task_id: session.id.clone(),
sub_session_id: session.session_id.clone(),
stream_message_id: std::sync::Mutex::new(None),
},
self.conversation_repository.clone(),

View File

@ -330,14 +330,10 @@ impl Tool for TodoWriteTool {
/// 计算 scope_key
/// - 主代理 (nesting_depth == 0):优先 topic_id否则 session_id
/// - 子/孙代理 (nesting_depth > 0):使用 task_id 隔离(全局唯一,与 list_todos 保持一致)
/// - 子/孙代理 (nesting_depth > 0):使用 session_id 隔离,避免污染父代理 todo
pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option<String> {
if context.nesting_depth > 0 {
// 使用 task_id 而不是 session_id 作为 scope_key。
// session_id 对于孙智能体包含父链(如 sub:sub:root:parent:task
// 而 list_todos handler 用根 session + task_id 拼接,两者不匹配。
// task_id 是全局唯一的 UUIDtask:xxx直接使用可避免层级不一致。
context.task_id.clone().filter(|s| !s.is_empty())
context.session_id.clone().filter(|s| !s.is_empty())
} else {
let tid = context.topic_id.as_deref().filter(|t| !t.is_empty());
let sid = context.session_id.as_deref().filter(|s| !s.is_empty());

View File

@ -324,8 +324,8 @@ function App() {
)
const handleNavigateToSubAgent = useCallback(
(taskId: string, description: string, subagentType?: string) => {
const cmd = enterSubAgentView(taskId, description, subagentType)
(taskId: string, description: string) => {
const cmd = enterSubAgentView(taskId, description)
handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
},
@ -474,13 +474,6 @@ function App() {
return result
}, [messages])
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置
const viewKey = useMemo(() => {
if (schedulerView) return `scheduler:${schedulerView.jobId}`
if (subAgentView) return `subagent:${subAgentView.taskId}`
return 'main'
}, [schedulerView, subAgentView])
return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
{/* Header */}
@ -693,7 +686,6 @@ function App() {
onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution}
showThinking={showThinking}
viewKey={viewKey}
todoPanel={
<TodoPanel
todos={todos}

View File

@ -8,13 +8,11 @@ interface ChatContainerProps {
isReadOnly?: boolean
channelName?: string
onSendMessage: (content: string, attachments: Attachment[]) => void
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onNavigateToSubAgent?: (taskId: string, description: string) => void
onStop?: () => void
showThinking?: boolean
/** 浮动待办面板,绝对定位在消息区域上方 */
todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string
}
export function ChatContainer({
@ -27,12 +25,11 @@ export function ChatContainer({
onStop,
showThinking = true,
todoPanel,
viewKey,
}: 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} />
{todoPanel}
</div>
<MessageInput

View File

@ -58,7 +58,7 @@ function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pen
interface MessageBubbleProps {
message: ChatMessage
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onNavigateToSubAgent?: (taskId: string, description: string) => void
showThinking?: boolean
}
@ -364,6 +364,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
const isTaskTool = message.toolName === 'task'
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null
const isSubAgent = !!message.subagentTaskId
const subagentType = (message.arguments as Record<string, unknown> | null)?.subagent_type as string || 'general'
const taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || ''
const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || ''
@ -394,6 +395,11 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
·{subagentType}
</span>
)}
{!isTaskTool && isSubAgent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-400">
</span>
)}
<span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
</div>
<div
@ -471,7 +477,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务', subagentType)
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
@ -480,12 +486,12 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</button>
</div>
)}
{isTaskTool && message.navigateToTaskId && !taskResult && (
{isTaskTool && message.subagentTaskId && !taskResult && (
<div className="px-3 pb-1">
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
@ -494,7 +500,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</button>
</div>
)}
{isTaskTool && !taskResult && !message.navigateToTaskId && (
{isTaskTool && !taskResult && !message.subagentTaskId && (
<div className="px-3 pb-2 text-xs text-[var(--text-muted)]">
...
</div>
@ -545,7 +551,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务', subagentType)
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>
@ -571,11 +577,11 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</pre>
</div>
)}
{isTaskTool && message.navigateToTaskId && (
{isTaskTool && message.subagentTaskId && (
<button
onClick={(e) => {
e.stopPropagation()
onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务')
}}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
>

View File

@ -5,33 +5,27 @@ import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
interface MessageListProps {
messages: ChatMessage[]
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
onNavigateToSubAgent?: (taskId: string, description: string) => void
showThinking?: boolean
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string
}
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) {
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const prevShowBottomRef = useRef(false)
const prevViewKeyRef = useRef(viewKey)
const viewKeyRef = useRef(viewKey)
viewKeyRef.current = viewKey
// Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map())
const prevShowTopRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [newMessageCount, setNewMessageCount] = useState(0)
const [showScrollToTop, setShowScrollToTop] = useState(false)
const [hasNewMessage, setHasNewMessage] = useState(false)
// ---- scroll helpers ----
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isAtBottomRef.current = true
setShowScrollToBottom(false)
setNewMessageCount(0)
setHasNewMessage(false)
bottomRef.current?.scrollIntoView({ behavior })
}, [])
@ -48,65 +42,41 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120
// Save scroll position for current view
const key = viewKeyRef.current
if (key) {
scrollPositionsRef.current.set(key, el.scrollTop)
}
isAtBottomRef.current = nearBottom
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
const shouldShowBottom = distanceFromBottom > 200
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8
if (shouldShowTop !== prevShowTopRef.current) {
prevShowTopRef.current = shouldShowTop
setShowScrollToTop(shouldShowTop)
}
const shouldShowBottom = !nearBottom
if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom)
}
// 滚回底部时清除新消息计数
if (nearBottom) {
setNewMessageCount(0)
if (nearBottom && hasNewMessage) {
setHasNewMessage(false)
}
}, [])
}, [hasNewMessage])
// ---- auto-scroll: handle view switches and message updates ----
// ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ----
useLayoutEffect(() => {
const prevKey = prevViewKeyRef.current
const viewChanged = prevKey !== viewKey
prevViewKeyRef.current = viewKey
if (messages.length === 0) {
isAtBottomRef.current = true
return
}
if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position
const key = viewKey ?? ''
const savedPos = scrollPositionsRef.current.get(key)
if (savedPos !== undefined && containerRef.current) {
containerRef.current.scrollTop = savedPos
const el = containerRef.current
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
isAtBottomRef.current = distanceFromBottom < 120
return
}
// First time viewing this view: scroll to bottom
isAtBottomRef.current = true
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
return
}
// Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1]
if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
} else {
setNewMessageCount((prev) => prev + 1)
setHasNewMessage(true)
}
}, [messages, viewKey])
}, [messages])
// ---- mount: always scroll to bottom if messages already loaded ----
@ -152,14 +122,14 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
<div ref={bottomRef} />
</div>
{/* 浮动导航按钮 — 底部居中并排 */}
{showScrollToBottom && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 回到顶部 */}
{/* 浮动按钮 */}
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
{showScrollToTop && (
<button
onClick={scrollToTop}
className="flex items-center gap-1.5 px-3 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
@ -168,15 +138,16 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
animate-fade-in"
aria-label="回到顶部"
>
<ArrowUp className="h-4 w-4" />
<span className="text-sm text-[var(--text-secondary)]"></span>
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" />
</button>
)}
{/* 回到底部 */}
{showScrollToBottom && (
<button
onClick={() => scrollToBottom('smooth')}
className="flex items-center gap-2 px-4 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
className="pointer-events-auto group relative flex items-center justify-center
w-9 h-9 rounded-full
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
@ -185,23 +156,17 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
animate-fade-in"
aria-label="回到底部"
>
<ArrowDown className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} />
{newMessageCount > 0 ? (
<span className="text-sm font-medium text-[var(--text-primary)]">
{newMessageCount}
</span>
) : (
<span className="text-sm text-[var(--text-secondary)]"></span>
)}
{newMessageCount > 0 && (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent-cyan)]" />
<ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} />
{hasNewMessage && (
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60"></span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--accent-cyan)]"></span>
</span>
)}
</button>
</div>
)}
)}
</div>
</div>
)
}

View File

@ -77,15 +77,11 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
localStorage.setItem('picobot-todo-expanded', String(expanded))
}, [expanded])
// auto-expand on new items, auto-collapse when empty
// auto-expand on new items
useEffect(() => {
const newIds = new Set(todos.map(t => t.id))
if (todos.length === 0) {
setExpanded(false)
} else {
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems) setExpanded(true)
}
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems && todos.length > 0) setExpanded(true)
prevTodoIdsRef.current = newIds
}, [todos])
@ -156,23 +152,26 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
>
<button
onClick={() => { if (totalCount > 0) setExpanded(true); else handleRefresh() }}
className="relative w-14 h-14 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-xl border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/50 shadow-[0_4px_24px_rgba(0,240,255,0.08)] hover:shadow-[0_4px_32px_var(--shadow-glow-sm)] transition-all duration-300 group"
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 items-center justify-center">
<ClipboardList className="h-5 w-5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" />
<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>
</div>
{totalCount > 0 && (
<span className={`absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1 rounded-full flex items-center justify-center text-[11px] font-bold leading-tight ${
<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 shadow-[0_0_12px_rgba(245,158,11,0.4)]'
? '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-[3px] rounded-full animate-todo-ring-pulse" />
<span className="absolute inset-0 rounded-full border-2 border-amber-400/30 animate-ping" />
)}
</button>
</div>
@ -187,44 +186,29 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
className="absolute z-30"
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
>
<div className="w-80 max-h-[55vh] flex flex-col rounded-2xl bg-[var(--bg-tertiary)]/95 backdrop-blur-md border border-[var(--border-color)] shadow-2xl hover:shadow-[0_8px_40px_var(--shadow-glow-sm)] overflow-hidden animate-todo-card-in">
<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-4 py-2.5 border-b border-[var(--border-color)]/50 cursor-grab active:cursor-grabbing select-none rounded-t-2xl"
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)]/40 text-sm tracking-[0.15em] leading-none select-none group-hover:text-[var(--accent-cyan)]/60 transition-colors"></span>
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span>
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
<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.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新">
<RefreshCw className="h-3.5 w-3.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.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors" title="缩小">
<ChevronDown className="h-4 w-4" />
<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>
</div>
</div>
{/* list */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-4 py-3 space-y-2">
{totalCount === 0 && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center select-none">
<div className="relative mb-4">
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
<ClipboardList className="relative h-10 w-10 text-[var(--accent-cyan)]/25" />
</div>
<p className="text-[12px] text-[var(--text-muted)] leading-relaxed mb-1">
</p>
<p className="text-[10px] text-[var(--text-muted)]/60 leading-relaxed">
AI
</p>
</div>
)}
<div className="flex-1 overflow-y-auto scrollbar-hide px-3 py-2">
{GROUP_ORDER.map(status => {
const items = grouped.get(status)
if (!items || items.length === 0) return null
@ -236,28 +220,30 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
<div key={status} className="mt-1 first:mt-0">
<button
onClick={() => toggleGroup(status)}
className="sticky top-0 z-10 flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-tertiary)]/95 backdrop-blur-sm"
className="flex items-center gap-1.5 w-full py-0.5 group"
>
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
<span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
<span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span>
<span className="ml-auto text-[var(--text-muted)]/50">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot} shrink-0`} />
<span className={`text-[10px] font-medium ${cfg.color}`}>{cfg.label}</span>
<span className={`text-[10px] ${cfg.color} opacity-60 tabular-nums`}>{items.length}</span>
<span className="ml-auto text-[var(--text-muted)]/40">
{isCollapsed
? <ChevronDown className="h-2.5 w-2.5 rotate-[-90deg]" />
: <ChevronDown className="h-2.5 w-2.5" />
}
</span>
</button>
<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">
{!isCollapsed && (
<div className="ml-3 border-l border-[var(--border-color)]/30 pl-2 mt-0.5 space-y-px">
{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">
<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">
<div key={item.id} className="py-[3px]">
<span className="text-[12px] leading-snug text-[var(--text-primary)]/90 break-words">
{item.content}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
})}

View File

@ -85,7 +85,7 @@ interface UseChatReturn {
selectSession: (sessionId: string) => void
// 子智能体导航方法
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
enterSubAgentView: (taskId: string, description: string) => Command
exitSubAgentView: () => void
navigateToSubAgentLevel: (index: number) => void
@ -206,6 +206,15 @@ export function useChat(): UseChatReturn {
return undefined
}
// Extract topic_id from a message if present
const getTopicId = (message: WsOutbound): string | undefined => {
if (message.type === 'tool_call' || message.type === 'tool_result'
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).topic_id
}
return undefined
}
// Convert a server message to ChatMessage (extracted from handleServerMessage logic)
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
switch (message.type) {
@ -386,30 +395,6 @@ export function useChat(): UseChatReturn {
return
}
// When the sub-agent spawns a grandchild, set navigateToTaskId
// on the task tool_call so "查看实时进度" navigates correctly.
if (message.type === 'task_started') {
const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
break
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
})
return
}
}
// Only accept messages explicitly tagged with matching subagent_task_id.
// History messages are now tagged by the backend (send_task_messages),
// and live sub-agent messages are tagged by SubAgentEmitter.
@ -430,8 +415,23 @@ export function useChat(): UseChatReturn {
}
// In main view, skip sub-agent messages (they belong to sub-agent view).
// But use the task_id to associate with the running task tool card.
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId) {
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
const msgTopicId = getTopicId(message)
if (msgTopicId && msgTopicId !== selectedTopicRef.current) return
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], subagentTaskId: msgSubagentTaskId }
return updated
}
}
return prev
})
return
}
@ -450,12 +450,12 @@ export function useChat(): UseChatReturn {
// 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) break
// 设置 navigateToTaskId,让用户可以点击查看实时进度
// 立即更新对应的 task tool_call,让用户可以点击查看实时进度
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
updated[i] = { ...updated[i], subagentTaskId: msg.task_id }
return updated
}
}
@ -857,11 +857,11 @@ export function useChat(): UseChatReturn {
selectedTopicRef.current = selectedTopic
}, [selectedTopic])
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
const enterSubAgentView = useCallback((taskId: string, description: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: subagentType || '',
subagentType: '',
status: 'loading',
messages: [],
}

View File

@ -258,60 +258,6 @@ body {
.animate-fade-in { animation: fade-in 0.2s ease-out; }
.animate-scale-in { animation: scale-in 0.3s ease-out; }
/* ============================================
TodoPanel animations
============================================ */
@keyframes todo-card-in {
from {
opacity: 0;
transform: scale(0.92) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes todo-item-in {
from {
opacity: 0;
transform: translateX(-6px);
max-height: 0;
}
to {
opacity: 1;
transform: translateX(0);
max-height: 40px;
}
}
@keyframes todo-ring-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
}
}
.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-group-body {
overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.todo-group-body-open {
max-height: 600px;
opacity: 1;
}
.todo-group-body-closed {
max-height: 0;
opacity: 0;
}
@keyframes thinking-reveal {
from { max-height: 0; opacity: 0; }
to { max-height: 300px; opacity: 1; }

View File

@ -453,10 +453,7 @@ export interface ChatMessage {
status?: 'calling' | 'result' | 'pending'
resultContent?: string
callContent?: string
/** 路由字段:标识消息属于哪个子智能体会话(与后端 subagent_task_id 一致) */
subagentTaskId?: string
/** 导航字段:仅 task 工具卡片使用,由 task_started 事件设置,指向新创建的子/孙智能体 task_id */
navigateToTaskId?: string
durationMs?: number
reasoningContent?: string
}