Compare commits
6 Commits
bf724b133c
...
edc975e6d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc975e6d0 | ||
|
|
9ea5849f22 | ||
|
|
e585ec71b1 | ||
|
|
421714dfa3 | ||
|
|
301506a3b1 | ||
|
|
8684ff9549 |
@ -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()
|
||||||
|
|||||||
@ -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 => {
|
||||||
return Ok(CommandResponse::success(ctx.request_id)
|
// 从 SessionManager 获取真实的 current topic
|
||||||
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
|
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())));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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(),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// 构建命令上下文
|
// 构建命令上下文
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 是全局唯一的 UUID(task: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());
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
|
if (todos.length === 0) {
|
||||||
if (hasNewItems && todos.length > 0) setExpanded(true)
|
setExpanded(false)
|
||||||
|
} else {
|
||||||
|
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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: [],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user