Compare commits

..

6 Commits

Author SHA1 Message Date
oudecheng
edc975e6d0 feat(command): 优化 StopExecution 命令按话题取消逻辑
- StopExecutionCommandHandler 新增 SessionManager 依赖
- 优先使用 ctx.topic_id,若无则从 SessionManager 获取当前话题
- 增加获取话题过程中错误处理和日志打印
- 更新命令处理器构造参数,传入 SessionManager
- 在命令路由和 WebSocket 注册时同步修改 StopExecutionCommandHandler 创建方式
2026-06-18 17:17:26 +08:00
oudecheng
9ea5849f22 refactor(task): 子智能体相关字段重命名及导航逻辑调整
- 将 runtime 中的 sub_session_id 字段重命名为 task_id,作为持久化的 scope_key
- 调整持久化 todo_write 结果使用 task_id 代替 session_id 作为 scope_key
- 重命名前端消息中的 subagentTaskId 为 navigateToTaskId,增强导航表达
- 修改 MessageBubble 组件中子智能体任务导航相关的字段名和条件判断
- 优化 useChat 中 task tool_call 消息的 navigateToTaskId 设置逻辑,确保正确导航孙智能体任务
- 移除无用的 getTopicId 辅助方法,简化消息处理逻辑
- 在协议类型定义中新增 navigateToTaskId 字段,明确导航用途与关系
2026-06-18 17:09:42 +08:00
oudecheng
e585ec71b1 refactor(todo): 统一子代理 scope_key 为全局唯一的 task_id
- 修改 list_todos 处理器,子代理使用 task_id 作为 scope_key,替代原先的 sub:{parent_session_id}:{task_id}
- 调整 todo_write 的 scope_key_from_context 函数,子/孙代理使用 task_id 隔离,避免与父代理污染
- 修正子任务消息的 task_id 传递逻辑,在 useChat hook 中为子代理视图的孙子任务回填正确的 task_id
- 清理 MessageBubble 组件中多余的 isSubAgent 变量声明
2026-06-18 16:44:54 +08:00
oudecheng
421714dfa3 style(TodoPanel): 优化待办面板动画和样式细节
- 新增待办卡片及条目动画效果,提升界面动感
- 添加待办分组折叠内容展开/收起过渡样式
- 优化按钮尺寸及颜色悬浮效果,提升交互体验
- 自动展开新待办项,空列表时自动收起
- 调整待办面板大小、圆角及阴影效果,增强视觉层次
- 改进待办标题栏排版及交互样式,支持拖动操作手感
- 为无待办状态提供占位展示及提示文字
- 优化待办分组标题样式及折叠箭头动画
- 改进待办条目列表项样式,支持悬浮高亮显示
- 移除消息气泡中的冗余子智能体标签显示内容
2026-06-18 16:15:08 +08:00
oudecheng
301506a3b1 feat(chat): 支持子智能体导航添加子智能体类型参数
- 扩展 enterSubAgentView 方法,新增 subagentType 可选参数
- 更新相关回调 onNavigateToSubAgent,添加 subagentType 参数支持
- 调整 MessageBubble 组件触发子智能体导航时传递 subagentType
- 优化 MessageList 组件显示新消息计数及底部导航按钮交互
- 美化底部浮动导航按钮样式,增加新消息数字提示和动画
- TodoPanel 添加状态点样式,消息内容排版更紧凑
- 维护滚动位置状态,改进滚动时新消息计数逻辑
2026-06-18 15:34:27 +08:00
oudecheng
8684ff9549 feat(chat): 实现多视图滚动位置保存与恢复功能
- 在 App 组件中新增 viewKey,用于标识当前视图
- 通过属性链传递 viewKey 至 MessageList 以区分不同滚动上下文
- MessageList 中新增滚动位置缓存机制,使用 Map 保存各视图的滚动状态
- 在视图切换时自动恢复对应滚动位置,提升用户体验
- 保持消息新增时的自动滚动行为,兼顾视图切换与消息更新场景
2026-06-18 15:02:25 +08:00
14 changed files with 289 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -330,10 +330,14 @@ impl Tool for TodoWriteTool {
/// 计算 scope_key /// 计算 scope_key
/// - 主代理 (nesting_depth == 0):优先 topic_id否则 session_id /// - 主代理 (nesting_depth == 0):优先 topic_id否则 session_id
/// - 子/孙代理 (nesting_depth > 0):使用 session_id 隔离,避免污染父代理 todo /// - 子/孙代理 (nesting_depth > 0):使用 task_id 隔离(全局唯一,与 list_todos 保持一致)
pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option<String> { pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option<String> {
if context.nesting_depth > 0 { if context.nesting_depth > 0 {
context.session_id.clone().filter(|s| !s.is_empty()) // 使用 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())
} else { } else {
let tid = context.topic_id.as_deref().filter(|t| !t.is_empty()); let tid = context.topic_id.as_deref().filter(|t| !t.is_empty());
let sid = context.session_id.as_deref().filter(|s| !s.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( const handleNavigateToSubAgent = useCallback(
(taskId: string, description: string) => { (taskId: string, description: string, subagentType?: string) => {
const cmd = enterSubAgentView(taskId, description) const cmd = enterSubAgentView(taskId, description, subagentType)
handleCommand(cmd) handleCommand(cmd)
sendMessage({ type: 'command', payload: JSON.stringify(cmd) }) sendMessage({ type: 'command', payload: JSON.stringify(cmd) })
}, },
@ -474,6 +474,13 @@ function App() {
return result return result
}, [messages]) }, [messages])
// 视图标识:用于 MessageList 保存/恢复每个视图的滚动位置
const viewKey = useMemo(() => {
if (schedulerView) return `scheduler:${schedulerView.jobId}`
if (subAgentView) return `subagent:${subAgentView.taskId}`
return 'main'
}, [schedulerView, subAgentView])
return ( return (
<div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden"> <div className="flex h-screen flex-col bg-[var(--bg-primary)] text-[var(--text-primary)] overflow-hidden">
{/* Header */} {/* Header */}
@ -686,6 +693,7 @@ function App() {
onNavigateToSubAgent={handleNavigateToSubAgent} onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey}
todoPanel={ todoPanel={
<TodoPanel <TodoPanel
todos={todos} todos={todos}

View File

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

View File

@ -58,7 +58,7 @@ function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pen
interface MessageBubbleProps { interface MessageBubbleProps {
message: ChatMessage message: ChatMessage
onNavigateToSubAgent?: (taskId: string, description: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
showThinking?: boolean showThinking?: boolean
} }
@ -364,7 +364,6 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
const isTaskTool = message.toolName === 'task' const isTaskTool = message.toolName === 'task'
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null 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 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 taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || ''
const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || '' const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || ''
@ -395,11 +394,6 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
·{subagentType} ·{subagentType}
</span> </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> <span className="text-xs text-[var(--text-muted)]">{formatTime(message.timestamp)}</span>
</div> </div>
<div <div
@ -477,7 +471,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务') onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务', subagentType)
}} }}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1" className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
> >
@ -486,12 +480,12 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</button> </button>
</div> </div>
)} )}
{isTaskTool && message.subagentTaskId && !taskResult && ( {isTaskTool && message.navigateToTaskId && !taskResult && (
<div className="px-3 pb-1"> <div className="px-3 pb-1">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务') onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
}} }}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1" className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
> >
@ -500,7 +494,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</button> </button>
</div> </div>
)} )}
{isTaskTool && !taskResult && !message.subagentTaskId && ( {isTaskTool && !taskResult && !message.navigateToTaskId && (
<div className="px-3 pb-2 text-xs text-[var(--text-muted)]"> <div className="px-3 pb-2 text-xs text-[var(--text-muted)]">
... ...
</div> </div>
@ -551,7 +545,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务') onNavigateToSubAgent?.(taskResult.task_id, taskDescription || '子智能体任务', subagentType)
}} }}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1" className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1"
> >
@ -577,11 +571,11 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
</pre> </pre>
</div> </div>
)} )}
{isTaskTool && message.subagentTaskId && ( {isTaskTool && message.navigateToTaskId && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onNavigateToSubAgent?.(message.subagentTaskId!, taskDescription || '子智能体任务') onNavigateToSubAgent?.(message.navigateToTaskId!, taskDescription || '子智能体任务', subagentType)
}} }}
className="text-xs text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 hover:underline transition-colors flex items-center gap-1" 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,27 +5,33 @@ import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[] messages: ChatMessage[]
onNavigateToSubAgent?: (taskId: string, description: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
showThinking?: boolean showThinking?: boolean
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string
} }
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true }: MessageListProps) { export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) const isAtBottomRef = useRef(true)
const prevShowBottomRef = useRef(false) const prevShowBottomRef = useRef(false)
const prevShowTopRef = 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 [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [showScrollToTop, setShowScrollToTop] = useState(false) const [newMessageCount, setNewMessageCount] = useState(0)
const [hasNewMessage, setHasNewMessage] = useState(false)
// ---- scroll helpers ---- // ---- scroll helpers ----
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isAtBottomRef.current = true isAtBottomRef.current = true
setShowScrollToBottom(false) setShowScrollToBottom(false)
setHasNewMessage(false) setNewMessageCount(0)
bottomRef.current?.scrollIntoView({ behavior }) bottomRef.current?.scrollIntoView({ behavior })
}, []) }, [])
@ -42,41 +48,65 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120 const nearBottom = distanceFromBottom < 120
isAtBottomRef.current = nearBottom // Save scroll position for current view
const key = viewKeyRef.current
const shouldShowTop = el.scrollTop > el.clientHeight * 0.8 if (key) {
if (shouldShowTop !== prevShowTopRef.current) { scrollPositionsRef.current.set(key, el.scrollTop)
prevShowTopRef.current = shouldShowTop
setShowScrollToTop(shouldShowTop)
} }
const shouldShowBottom = !nearBottom isAtBottomRef.current = nearBottom
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
const shouldShowBottom = distanceFromBottom > 200
if (shouldShowBottom !== prevShowBottomRef.current) { if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom) setShowScrollToBottom(shouldShowBottom)
} }
if (nearBottom && hasNewMessage) { // 滚回底部时清除新消息计数
setHasNewMessage(false) if (nearBottom) {
setNewMessageCount(0)
} }
}, [hasNewMessage]) }, [])
// ---- auto-scroll: useLayoutEffect runs before browser processes scroll events ---- // ---- auto-scroll: handle view switches and message updates ----
useLayoutEffect(() => { useLayoutEffect(() => {
const prevKey = prevViewKeyRef.current
const viewChanged = prevKey !== viewKey
prevViewKeyRef.current = viewKey
if (messages.length === 0) { if (messages.length === 0) {
isAtBottomRef.current = true isAtBottomRef.current = true
return 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] const lastMessage = messages[messages.length - 1]
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' }) bottomRef.current?.scrollIntoView({ behavior: 'instant' })
} else { } else {
setHasNewMessage(true) setNewMessageCount((prev) => prev + 1)
} }
}, [messages]) }, [messages, viewKey])
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----
@ -122,14 +152,14 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
{/* 浮动按钮 */} {/* 浮动导航按钮 — 底部居中并排 */}
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none"> {showScrollToBottom && (
{showScrollToTop && ( <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 回到顶部 */}
<button <button
onClick={scrollToTop} onClick={scrollToTop}
className="pointer-events-auto group relative flex items-center justify-center className="flex items-center gap-1.5 px-3 py-2 rounded-full
w-9 h-9 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-md
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)] border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/30
@ -138,16 +168,15 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
animate-fade-in" animate-fade-in"
aria-label="回到顶部" aria-label="回到顶部"
> >
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" /> <ArrowUp className="h-4 w-4" />
<span className="text-sm text-[var(--text-secondary)]"></span>
</button> </button>
)}
{showScrollToBottom && ( {/* 回到底部 */}
<button <button
onClick={() => scrollToBottom('smooth')} onClick={() => scrollToBottom('smooth')}
className="pointer-events-auto group relative flex items-center justify-center className="flex items-center gap-2 px-4 py-2 rounded-full
w-9 h-9 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-md
bg-[var(--bg-tertiary)]/80 backdrop-blur-md
border border-[var(--border-color)] border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/30
@ -156,17 +185,23 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
animate-fade-in" animate-fade-in"
aria-label="回到底部" aria-label="回到底部"
> >
<ArrowDown className={`h-4 w-4 transition-transform duration-300 group-hover:translate-y-0.5 ${hasNewMessage ? 'animate-bounce' : ''}`} /> <ArrowDown className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} />
{newMessageCount > 0 ? (
{hasNewMessage && ( <span className="text-sm font-medium text-[var(--text-primary)]">
<span className="absolute -top-0.5 -right-0.5 flex h-2.5 w-2.5"> {newMessageCount}
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60"></span> </span>
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--accent-cyan)]"></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)]" />
</span> </span>
)} )}
</button> </button>
)}
</div> </div>
)}
</div> </div>
) )
} }

View File

@ -77,11 +77,15 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
localStorage.setItem('picobot-todo-expanded', String(expanded)) localStorage.setItem('picobot-todo-expanded', String(expanded))
}, [expanded]) }, [expanded])
// auto-expand on new items // auto-expand on new items, auto-collapse when empty
useEffect(() => { useEffect(() => {
const newIds = new Set(todos.map(t => t.id)) 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)) const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems && todos.length > 0) setExpanded(true) if (hasNewItems) setExpanded(true)
}
prevTodoIdsRef.current = newIds prevTodoIdsRef.current = newIds
}, [todos]) }, [todos])
@ -152,26 +156,23 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
> >
<button <button
onClick={() => { if (totalCount > 0) setExpanded(true); else handleRefresh() }} 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" 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"
title="待办" title="待办"
> >
<div className="flex flex-col items-center justify-center"> <div className="flex items-center justify-center">
<ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)] transition-colors" /> <ClipboardList className="h-5 w-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> </div>
{totalCount > 0 && ( {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 ${ <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 ${
inProgressCount > 0 inProgressCount > 0
? 'bg-amber-400 text-black' ? 'bg-amber-400 text-black shadow-[0_0_12px_rgba(245,158,11,0.4)]'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'
}`}> }`}>
{totalCount} {totalCount}
</span> </span>
)} )}
{inProgressCount > 0 && ( {inProgressCount > 0 && (
<span className="absolute inset-0 rounded-full border-2 border-amber-400/30 animate-ping" /> <span className="absolute -inset-[3px] rounded-full animate-todo-ring-pulse" />
)} )}
</button> </button>
</div> </div>
@ -186,29 +187,44 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
className="absolute z-30" className="absolute z-30"
style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }} style={{ top: `${16 + pos.y}px`, right: `${16 + pos.x}px` }}
> >
<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"> <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">
{/* title bar (drag handle) */} {/* title bar (drag handle) */}
<div <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" 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"
onMouseDown={handleDragStart} onMouseDown={handleDragStart}
> >
<span className="text-[var(--text-muted)]/30 text-[10px] leading-none select-none"></span> <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-3 w-3 text-[var(--accent-cyan)]" /> <ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[11px] font-semibold text-[var(--text-secondary)]"></span> <span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span>
<span className="text-[10px] text-[var(--text-muted)]/70 tabular-nums">{totalCount}</span> <span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
{inProgressCount > 0 && <PulseDot />} {inProgressCount > 0 && <PulseDot />}
<div className="ml-auto flex items-center gap-0.5"> <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="刷新"> <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 w-3" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
<button onClick={() => setExpanded(false)} className="p-1 rounded text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] transition-colors" title="缩小"> <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-3 w-3" /> <ChevronDown className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
{/* list */} {/* list */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-3 py-2"> <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>
)}
{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
@ -220,30 +236,28 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
<div key={status} className="mt-1 first:mt-0"> <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 py-0.5 group" 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"
> >
<span className={`h-1.5 w-1.5 rounded-full ${cfg.dot} shrink-0`} /> <span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
<span className={`text-[10px] font-medium ${cfg.color}`}>{cfg.label}</span> <span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
<span className={`text-[10px] ${cfg.color} opacity-60 tabular-nums`}>{items.length}</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)]/40"> <span className="ml-auto text-[var(--text-muted)]/50">
{isCollapsed <ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
? <ChevronDown className="h-2.5 w-2.5 rotate-[-90deg]" />
: <ChevronDown className="h-2.5 w-2.5" />
}
</span> </span>
</button> </button>
{!isCollapsed && ( <div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
<div className="ml-3 border-l border-[var(--border-color)]/30 pl-2 mt-0.5 space-y-px"> <div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
{items.map(item => ( {items.map(item => (
<div key={item.id} className="py-[3px]"> <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="text-[12px] leading-snug text-[var(--text-primary)]/90 break-words"> <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">
{item.content} {item.content}
</span> </span>
</div> </div>
))} ))}
</div> </div>
)} </div>
</div> </div>
) )
})} })}

View File

@ -85,7 +85,7 @@ interface UseChatReturn {
selectSession: (sessionId: string) => void selectSession: (sessionId: string) => void
// 子智能体导航方法 // 子智能体导航方法
enterSubAgentView: (taskId: string, description: string) => Command enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
exitSubAgentView: () => void exitSubAgentView: () => void
navigateToSubAgentLevel: (index: number) => void navigateToSubAgentLevel: (index: number) => void
@ -206,15 +206,6 @@ export function useChat(): UseChatReturn {
return undefined 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) // Convert a server message to ChatMessage (extracted from handleServerMessage logic)
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => { const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
switch (message.type) { switch (message.type) {
@ -395,6 +386,30 @@ export function useChat(): UseChatReturn {
return 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. // Only accept messages explicitly tagged with matching subagent_task_id.
// History messages are now tagged by the backend (send_task_messages), // History messages are now tagged by the backend (send_task_messages),
// and live sub-agent messages are tagged by SubAgentEmitter. // and live sub-agent messages are tagged by SubAgentEmitter.
@ -415,23 +430,8 @@ export function useChat(): UseChatReturn {
} }
// In main view, skip sub-agent messages (they belong to sub-agent view). // 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) const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId) { 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 return
} }
@ -450,12 +450,12 @@ export function useChat(): UseChatReturn {
// 孙智能体的 TaskStarted 不应 backfill 到主视图 // 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) break if (msg.parent_task_id) break
// 立即更新对应的 task tool_call,让用户可以点击查看实时进度 // 设置 navigateToTaskId,让用户可以点击查看实时进度
setMessages((prev) => { setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) { for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) { if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
const updated = [...prev] const updated = [...prev]
updated[i] = { ...updated[i], subagentTaskId: msg.task_id } updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
return updated return updated
} }
} }
@ -857,11 +857,11 @@ export function useChat(): UseChatReturn {
selectedTopicRef.current = selectedTopic selectedTopicRef.current = selectedTopic
}, [selectedTopic]) }, [selectedTopic])
const enterSubAgentView = useCallback((taskId: string, description: string): Command => { const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
const newView: SubAgentView = { const newView: SubAgentView = {
taskId, taskId,
description, description,
subagentType: '', subagentType: subagentType || '',
status: 'loading', status: 'loading',
messages: [], messages: [],
} }

View File

@ -258,6 +258,60 @@ body {
.animate-fade-in { animation: fade-in 0.2s ease-out; } .animate-fade-in { animation: fade-in 0.2s ease-out; }
.animate-scale-in { animation: scale-in 0.3s 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 { @keyframes thinking-reveal {
from { max-height: 0; opacity: 0; } from { max-height: 0; opacity: 0; }
to { max-height: 300px; opacity: 1; } to { max-height: 300px; opacity: 1; }

View File

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