feat: 添加滚动控制功能,支持回到顶部和回到底部的按钮
This commit is contained in:
parent
e8a3a47ac7
commit
2f529e96d0
@ -853,121 +853,6 @@ impl AgentLoop {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
/// Sanitize message history by removing assistant messages with tool_calls
|
||||
/// that don't have corresponding tool result messages, at ANY position in
|
||||
/// the history (not just trailing).
|
||||
///
|
||||
/// Incomplete sequences can appear in the middle of history when:
|
||||
/// 1. The process was interrupted mid-execution (before commit cb58d9f),
|
||||
/// then a new user message was appended, burying the orphan.
|
||||
/// 2. History compaction preserves orphaned tool_calls from a pre-fix era
|
||||
/// or from a race condition between persistence and snapshot.
|
||||
///
|
||||
/// Sending such incomplete sequences to the API causes errors like
|
||||
/// "insufficient tool messages following tool_calls message".
|
||||
///
|
||||
/// Returns the number of messages removed.
|
||||
fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut removed = 0;
|
||||
|
||||
// Phase 1: Single reverse pass to find ALL assistant messages with
|
||||
// incomplete tool_calls, regardless of position.
|
||||
//
|
||||
// Scanning right-to-left means we encounter tool results before their
|
||||
// parent assistants, so we naturally know which tool_call_ids have
|
||||
// corresponding results.
|
||||
let mut resolved_ids: HashSet<String> = HashSet::new();
|
||||
let mut with_parent: HashSet<String> = HashSet::new();
|
||||
let mut remove_indices: Vec<usize> = Vec::new();
|
||||
|
||||
// Phase 1: Reverse pass — collect tool result IDs first, then
|
||||
// validate each assistant's tool_calls against already-seen results.
|
||||
//
|
||||
// Because we scan right-to-left, any tool result we've already seen
|
||||
// appears AFTER the current message in forward order. This correctly
|
||||
// identifies which assistant tool_calls have corresponding results.
|
||||
for i in (0..messages.len()).rev() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if msg.role == "tool" {
|
||||
if let Some(ref tc_id) = msg.tool_call_id {
|
||||
resolved_ids.insert(tc_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if msg.role == "assistant"
|
||||
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
||||
{
|
||||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||||
let all_have_results = tool_calls
|
||||
.iter()
|
||||
.all(|tc| resolved_ids.contains(&tc.id));
|
||||
|
||||
if all_have_results {
|
||||
for tc in tool_calls.iter() {
|
||||
with_parent.insert(tc.id.clone());
|
||||
}
|
||||
} else {
|
||||
let missing_count = tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !resolved_ids.contains(&tc.id))
|
||||
.count();
|
||||
|
||||
tracing::warn!(
|
||||
tool_call_count = tool_calls.len(),
|
||||
missing_tool_results = missing_count,
|
||||
message_id = %msg.id,
|
||||
message_index = i,
|
||||
"Removing assistant message with incomplete tool call sequence — \
|
||||
tool results were never persisted (likely due to process interruption \
|
||||
or history compaction preserving an orphan)"
|
||||
);
|
||||
|
||||
remove_indices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in descending index order to avoid shifting
|
||||
for &idx in &remove_indices {
|
||||
messages.remove(idx);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
|
||||
// trailing ones). A tool message is orphaned if its tool_call_id has no
|
||||
// matching parent assistant remaining in the history.
|
||||
if !with_parent.is_empty() || !resolved_ids.is_empty() {
|
||||
let mut i = 0;
|
||||
while i < messages.len() {
|
||||
let msg = &messages[i];
|
||||
if msg.role == "tool" {
|
||||
let is_orphaned = match &msg.tool_call_id {
|
||||
Some(tc_id) => !with_parent.contains(tc_id),
|
||||
None => true,
|
||||
};
|
||||
if is_orphaned {
|
||||
tracing::warn!(
|
||||
tool_call_id = ?msg.tool_call_id,
|
||||
message_id = %msg.id,
|
||||
message_index = i,
|
||||
"Removing orphaned tool result message — its parent assistant \
|
||||
tool_calls message was removed or never persisted"
|
||||
);
|
||||
messages.remove(i);
|
||||
removed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Process a message using the provided conversation history.
|
||||
/// History management is handled externally by SessionManager.
|
||||
///
|
||||
@ -993,7 +878,7 @@ impl AgentLoop {
|
||||
|
||||
// Sanitize: remove any trailing incomplete tool call sequences
|
||||
// that may have been persisted before a process interruption.
|
||||
Self::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
|
||||
// Track tool calls for loop detection
|
||||
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
||||
@ -1936,7 +1821,7 @@ mod tests {
|
||||
// Tool result for call_1 is MISSING — incomplete sequence
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
@ -1957,7 +1842,7 @@ mod tests {
|
||||
ChatMessage::tool("call_1", "calculator", "2"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(messages.len(), 3);
|
||||
}
|
||||
@ -1987,7 +1872,7 @@ mod tests {
|
||||
// Also missing tool result for call_2
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// Should remove both trailing assistant messages with incomplete tool calls
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(messages.len(), 2);
|
||||
@ -2021,7 +1906,7 @@ mod tests {
|
||||
// Missing tool result for call_2
|
||||
];
|
||||
|
||||
let removed_count = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// Phase 1 removes the assistant message (call_2 has no result).
|
||||
// Phase 2 removes the orphaned tool result for call_1 (its parent
|
||||
// assistant was removed).
|
||||
@ -2038,7 +1923,7 @@ mod tests {
|
||||
ChatMessage::user("how are you"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(messages.len(), 3);
|
||||
}
|
||||
@ -2046,7 +1931,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_sanitize_handles_empty_messages() {
|
||||
let mut messages: Vec<ChatMessage> = vec![];
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 0);
|
||||
}
|
||||
|
||||
@ -2058,7 +1943,7 @@ mod tests {
|
||||
ChatMessage::tool("call_1", "calculator", "2"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 1);
|
||||
assert!(messages.is_empty());
|
||||
}
|
||||
@ -2086,7 +1971,7 @@ mod tests {
|
||||
ChatMessage::tool("call_2", "read", "contents of README"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(messages.len(), 4);
|
||||
}
|
||||
@ -2119,7 +2004,7 @@ mod tests {
|
||||
// Missing tool result for call_2 — only THIS sequence should be trimmed
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 1);
|
||||
// First complete sequence preserved (5 messages), user message for second
|
||||
// question preserved
|
||||
@ -2157,7 +2042,7 @@ mod tests {
|
||||
ChatMessage::tool("call_2", "read", "file contents"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// call_1 assistant removed (1), rest preserved (4)
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(messages.len(), 4);
|
||||
@ -2197,7 +2082,7 @@ mod tests {
|
||||
ChatMessage::user("third"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0].content, "first");
|
||||
@ -2242,7 +2127,7 @@ mod tests {
|
||||
ChatMessage::tool("call_valid", "good_tool", "valid result"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// call_has_result/call_no_result assistant (1) + its orphaned tool result (1) = 2 removed
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(messages.len(), 4);
|
||||
@ -2304,7 +2189,7 @@ mod tests {
|
||||
ChatMessage::assistant("task 3 is done"),
|
||||
];
|
||||
|
||||
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// Removed: assistant with t2_call_1/t2_call_2 (1 message)
|
||||
assert_eq!(removed, 1);
|
||||
// Original 10 messages - 1 = 9
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::domain::messages::ToolCall;
|
||||
|
||||
@ -237,6 +237,123 @@ impl ChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message sanitization
|
||||
// ============================================================================
|
||||
|
||||
/// Sanitize message history by removing assistant messages with `tool_calls`
|
||||
/// that don't have corresponding tool result messages, at ANY position in
|
||||
/// the history (not just trailing).
|
||||
///
|
||||
/// Incomplete sequences can appear in the middle of history when:
|
||||
/// 1. The process was interrupted mid-execution, then a new user message
|
||||
/// was appended, burying the orphan.
|
||||
/// 2. History compaction preserves orphaned `tool_calls` from a pre-fix era
|
||||
/// or from a race condition between persistence and snapshot.
|
||||
///
|
||||
/// Sending such incomplete sequences to the API causes errors like
|
||||
/// "insufficient tool messages following tool_calls message".
|
||||
///
|
||||
/// Returns the number of messages removed.
|
||||
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
|
||||
let mut removed = 0;
|
||||
|
||||
// Phase 1: Single reverse pass to find ALL assistant messages with
|
||||
// incomplete tool_calls, regardless of position.
|
||||
//
|
||||
// Scanning right-to-left means we encounter tool results before their
|
||||
// parent assistants, so we naturally know which tool_call_ids have
|
||||
// corresponding results.
|
||||
let mut resolved_ids: HashSet<String> = HashSet::new();
|
||||
let mut with_parent: HashSet<String> = HashSet::new();
|
||||
let mut remove_indices: Vec<usize> = Vec::new();
|
||||
|
||||
// Reverse pass — collect tool result IDs first, then validate each
|
||||
// assistant's tool_calls against already-seen results.
|
||||
//
|
||||
// Because we scan right-to-left, any tool result we've already seen
|
||||
// appears AFTER the current message in forward order. This correctly
|
||||
// identifies which assistant tool_calls have corresponding results.
|
||||
for i in (0..messages.len()).rev() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if msg.role == "tool" {
|
||||
if let Some(ref tc_id) = msg.tool_call_id {
|
||||
resolved_ids.insert(tc_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if msg.role == "assistant"
|
||||
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
||||
{
|
||||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||||
let all_have_results = tool_calls
|
||||
.iter()
|
||||
.all(|tc| resolved_ids.contains(&tc.id));
|
||||
|
||||
if all_have_results {
|
||||
for tc in tool_calls.iter() {
|
||||
with_parent.insert(tc.id.clone());
|
||||
}
|
||||
} else {
|
||||
let missing_count = tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !resolved_ids.contains(&tc.id))
|
||||
.count();
|
||||
|
||||
tracing::warn!(
|
||||
tool_call_count = tool_calls.len(),
|
||||
missing_tool_results = missing_count,
|
||||
message_id = %msg.id,
|
||||
message_index = i,
|
||||
"Removing assistant message with incomplete tool call sequence — \
|
||||
tool results were never persisted (likely due to process interruption \
|
||||
or history compaction preserving an orphan)"
|
||||
);
|
||||
|
||||
remove_indices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in descending index order to avoid shifting
|
||||
for &idx in &remove_indices {
|
||||
messages.remove(idx);
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
|
||||
// trailing ones). A tool message is orphaned if its tool_call_id has no
|
||||
// matching parent assistant remaining in the history.
|
||||
if !with_parent.is_empty() || !resolved_ids.is_empty() {
|
||||
let mut i = 0;
|
||||
while i < messages.len() {
|
||||
let msg = &messages[i];
|
||||
if msg.role == "tool" {
|
||||
let is_orphaned = match &msg.tool_call_id {
|
||||
Some(tc_id) => !with_parent.contains(tc_id),
|
||||
None => true,
|
||||
};
|
||||
if is_orphaned {
|
||||
tracing::warn!(
|
||||
tool_call_id = ?msg.tool_call_id,
|
||||
message_id = %msg.id,
|
||||
message_index = i,
|
||||
"Removing orphaned tool result message — its parent assistant \
|
||||
tool_calls message was removed or never persisted"
|
||||
);
|
||||
messages.remove(i);
|
||||
removed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InboundMessage - Message from Channel to Bus (user input)
|
||||
// ============================================================================
|
||||
|
||||
@ -729,29 +729,45 @@ impl SessionStore {
|
||||
|
||||
let delta_messages =
|
||||
load_messages_between(&tx, session_id, snapshot_end_seq, current_max_seq)?;
|
||||
let mut next_seq = current_max_seq + 1;
|
||||
let now = current_timestamp();
|
||||
|
||||
// Collect all new messages first, then sanitize incomplete tool call
|
||||
// sequences before writing to DB. This prevents orphaned tool_calls
|
||||
// (without corresponding tool results) from being persisted permanently
|
||||
// when compaction preserves an incomplete sequence from the snapshot or
|
||||
// captures a partial sequence from delta messages.
|
||||
let mut new_messages: Vec<ChatMessage> = Vec::new();
|
||||
|
||||
for message in preserved_system_messages {
|
||||
new_messages.push(clone_message_for_compaction(message, message.timestamp));
|
||||
}
|
||||
|
||||
new_messages.push(clone_message_for_compaction(summary_message, now));
|
||||
|
||||
for message in preserved_messages.iter().chain(delta_messages.iter()) {
|
||||
new_messages.push(clone_message_for_compaction(message, message.timestamp));
|
||||
}
|
||||
|
||||
let removed =
|
||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut new_messages);
|
||||
if removed > 0 {
|
||||
tracing::warn!(
|
||||
removed_count = removed,
|
||||
session_id = %session_id,
|
||||
"Compaction removed incomplete tool call sequences from new history"
|
||||
);
|
||||
}
|
||||
|
||||
// Write sanitized messages to DB
|
||||
let mut next_seq = current_max_seq + 1;
|
||||
let mut inserted_count = 0_i64;
|
||||
let mut active_user_turn_count = 0_i64;
|
||||
|
||||
for message in preserved_system_messages {
|
||||
let copied = clone_message_for_compaction(message, message.timestamp);
|
||||
insert_message_with_seq(&tx, session_id, next_seq, &copied)?;
|
||||
next_seq += 1;
|
||||
inserted_count += 1;
|
||||
}
|
||||
|
||||
let summary_copy = clone_message_for_compaction(summary_message, now);
|
||||
insert_message_with_seq(&tx, session_id, next_seq, &summary_copy)?;
|
||||
next_seq += 1;
|
||||
inserted_count += 1;
|
||||
|
||||
for message in preserved_messages.iter().chain(delta_messages.iter()) {
|
||||
let copied = clone_message_for_compaction(message, message.timestamp);
|
||||
if copied.role == "user" {
|
||||
for message in &new_messages {
|
||||
if message.role == "user" {
|
||||
active_user_turn_count += 1;
|
||||
}
|
||||
insert_message_with_seq(&tx, session_id, next_seq, &copied)?;
|
||||
insert_message_with_seq(&tx, session_id, next_seq, message)?;
|
||||
next_seq += 1;
|
||||
inserted_count += 1;
|
||||
}
|
||||
|
||||
@ -379,11 +379,6 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!taskResult && !isTaskTool && !hasResult && (
|
||||
<div className="px-3 pb-2 text-xs text-zinc-500">
|
||||
等待工具执行...
|
||||
</div>
|
||||
)}
|
||||
{isTaskTool && !taskResult && !message.subagentTaskId && (
|
||||
<div className="px-3 pb-2 text-xs text-zinc-500">
|
||||
子智能体正在执行...
|
||||
@ -455,9 +450,6 @@ export function MessageBubble({ message, onNavigateToSubAgent }: MessageBubblePr
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!hasArgs && !hasResult && (
|
||||
<div className="text-xs text-zinc-500">等待工具执行...</div>
|
||||
)}
|
||||
{isTaskTool && message.subagentTaskId && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import type { ChatMessage } from '../../types/protocol'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[]
|
||||
@ -11,12 +11,121 @@ interface MessageListProps {
|
||||
export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isStickyRef = useRef(true)
|
||||
const prevShowBottomRef = useRef(false)
|
||||
const prevShowTopRef = useRef(false)
|
||||
const lastMessageCountRef = useRef(0)
|
||||
const isProgrammaticScrollRef = useRef(false)
|
||||
const scrollTimerRef = useRef<number>(0)
|
||||
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [showScrollToTop, setShowScrollToTop] = useState(false)
|
||||
const [hasNewMessage, setHasNewMessage] = useState(false)
|
||||
|
||||
// ---- scroll handlers ----
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
const nearBottom = distanceFromBottom < 120
|
||||
|
||||
isStickyRef.current = nearBottom
|
||||
|
||||
// 编程式滚动进行中(按钮点击触发),跳过按钮状态更新,避免闪烁
|
||||
if (isProgrammaticScrollRef.current) return
|
||||
|
||||
// 回到顶部按钮:滚过 0.8 个视口时显示
|
||||
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 && hasNewMessage) {
|
||||
setHasNewMessage(false)
|
||||
}
|
||||
}, [hasNewMessage])
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||
isStickyRef.current = true
|
||||
prevShowBottomRef.current = false
|
||||
setShowScrollToBottom(false)
|
||||
setHasNewMessage(false)
|
||||
isProgrammaticScrollRef.current = true
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
isProgrammaticScrollRef.current = false
|
||||
}, 500)
|
||||
bottomRef.current?.scrollIntoView({ behavior })
|
||||
}, [])
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
isProgrammaticScrollRef.current = true
|
||||
clearTimeout(scrollTimerRef.current)
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
isProgrammaticScrollRef.current = false
|
||||
}, 500)
|
||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
// ---- auto-scroll effect ----
|
||||
|
||||
useEffect(() => {
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
if (messages.length === 0) return
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// 用户自己发的消息 → 始终滚到底部
|
||||
if (lastMessage.role === 'user') {
|
||||
scrollToBottom('instant')
|
||||
return
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// 用户在底部 → 自动跟随新消息
|
||||
if (isStickyRef.current && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'instant' })
|
||||
}
|
||||
|
||||
// 用户不在底部且有新消息 → 标记未读
|
||||
if (!isStickyRef.current && messages.length > lastMessageCountRef.current) {
|
||||
setHasNewMessage(true)
|
||||
}
|
||||
|
||||
lastMessageCountRef.current = messages.length
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
// ---- ResizeObserver: 窗口大小变化时保持底部对齐 ----
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isStickyRef.current && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: 'instant' })
|
||||
}
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// ---- 清理定时器 ----
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(scrollTimerRef.current)
|
||||
}, [])
|
||||
|
||||
// ---- empty state ----
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
@ -36,15 +145,69 @@ export function MessageList({ messages, onNavigateToSubAgent }: MessageListProps
|
||||
)
|
||||
}
|
||||
|
||||
// ---- main render ----
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full overflow-y-auto p-6 space-y-6"
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto p-6 space-y-6 scroll-smooth"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* 浮动导航按钮组 */}
|
||||
<div className="absolute right-4 bottom-4 z-10 flex flex-col items-center gap-2 pointer-events-none">
|
||||
{/* 回到顶部 */}
|
||||
{showScrollToTop && (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="pointer-events-auto group relative flex items-center justify-center
|
||||
w-9 h-9 rounded-full
|
||||
bg-[#1a1a25]/80 backdrop-blur-md
|
||||
border border-white/[0.06]
|
||||
text-zinc-500 hover:text-[#00f0ff]
|
||||
hover:border-[#00f0ff]/30
|
||||
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
|
||||
transition-all duration-300 ease-out
|
||||
animate-fade-in"
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 回到底部 */}
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={() => scrollToBottom('smooth')}
|
||||
className="pointer-events-auto group relative flex items-center justify-center
|
||||
w-9 h-9 rounded-full
|
||||
bg-[#1a1a25]/80 backdrop-blur-md
|
||||
border border-white/[0.06]
|
||||
text-zinc-500 hover:text-[#00f0ff]
|
||||
hover:border-[#00f0ff]/30
|
||||
hover:shadow-[0_0_20px_rgba(0,240,255,0.12)]
|
||||
transition-all duration-300 ease-out
|
||||
animate-fade-in"
|
||||
aria-label="回到底部"
|
||||
>
|
||||
<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-[#00f0ff]/60"></span>
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[#00f0ff]"></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user