PicoBot/web/src/components/Chat/MessageList.tsx
oudecheng 4efc8b51e7 feat(todo): 添加待办项关联的创建消息ID并支持消息高亮
- 在待办相关数据结构和存储中新增 created_by_message_id 字段
- 记录待办项创建时对应的消息ID,支持追溯来源
- 在前端待办列表项增加点击事件,点击后滚动并高亮对应消息
- 在消息列表组件中实现高亮动画及自动滚动功能
- 更新相关工具、协议和数据库查询,确保新字段正确传递和存储
- 增加 CSS 动画实现待办对应消息的高亮闪烁效果
- 优化前端状态管理,支持设置与获取高亮消息ID
2026-06-22 14:50:37 +08:00

232 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'
import { MessageBubble } from './MessageBubble'
import type { ChatMessage } from '../../types/protocol'
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
interface MessageListProps {
messages: ChatMessage[]
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
showThinking?: boolean
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string
/** 高亮的消息 ID点击待办项后滚动并高亮显示 */
highlightedMessageId?: string | null
}
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const prevShowBottomRef = useRef(false)
const prevViewKeyRef = useRef(viewKey)
const viewKeyRef = useRef(viewKey)
viewKeyRef.current = viewKey
// Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map())
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [newMessageCount, setNewMessageCount] = useState(0)
// ---- scroll helpers ----
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isAtBottomRef.current = true
setShowScrollToBottom(false)
setNewMessageCount(0)
bottomRef.current?.scrollIntoView({ behavior })
}, [])
const scrollToTop = useCallback(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
}, [])
// ---- scroll event: track whether user is at bottom ----
const handleScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const nearBottom = distanceFromBottom < 120
// Save scroll position for current view
const key = viewKeyRef.current
if (key) {
scrollPositionsRef.current.set(key, el.scrollTop)
}
isAtBottomRef.current = nearBottom
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
const shouldShowBottom = distanceFromBottom > 200
if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom
setShowScrollToBottom(shouldShowBottom)
}
// 滚回底部时清除新消息计数
if (nearBottom) {
setNewMessageCount(0)
}
}, [])
// ---- auto-scroll: handle view switches and message updates ----
useLayoutEffect(() => {
const prevKey = prevViewKeyRef.current
const viewChanged = prevKey !== viewKey
prevViewKeyRef.current = viewKey
if (messages.length === 0) {
isAtBottomRef.current = true
return
}
if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position
const key = viewKey ?? ''
const savedPos = scrollPositionsRef.current.get(key)
if (savedPos !== undefined && containerRef.current) {
containerRef.current.scrollTop = savedPos
const el = containerRef.current
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
isAtBottomRef.current = distanceFromBottom < 120
return
}
// First time viewing this view: scroll to bottom
isAtBottomRef.current = true
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
return
}
// Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1]
if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
} else {
setNewMessageCount((prev) => prev + 1)
}
}, [messages, viewKey])
// ---- mount: always scroll to bottom if messages already loaded ----
useEffect(() => {
if (messages.length > 0) {
scrollToBottom('instant')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ---- highlight and scroll to todo message ----
useEffect(() => {
if (!highlightedMessageId) return
const container = containerRef.current
if (!container) return
// 查找目标消息元素
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
if (!targetElement) return
// 滚动到目标位置
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 添加高亮样式
targetElement.classList.add('todo-highlight')
setTimeout(() => {
targetElement.classList.remove('todo-highlight')
}, 2000)
}, [highlightedMessageId])
// ---- empty state ----
if (messages.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center animate-fade-in">
<div className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-[var(--accent-cyan)]/20 to-[var(--accent-blue)]/20 shadow-2xl shadow-[var(--accent-cyan)]/20">
<Sparkles className="h-10 w-10 text-[var(--accent-cyan)]" />
</div>
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2>
<p className="text-[var(--text-muted)]"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/new </span>
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/list </span>
</div>
</div>
</div>
)
}
// ---- main render ----
return (
<div className="relative h-full">
<div
ref={containerRef}
onScroll={handleScroll}
className="h-full overflow-y-auto p-6 space-y-6"
>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} />
))}
<div ref={bottomRef} />
</div>
{/* 浮动导航按钮 — 底部居中并排 */}
{showScrollToBottom && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
{/* 回到顶部 */}
<button
onClick={scrollToTop}
className="flex items-center gap-1.5 px-3 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到顶部"
>
<ArrowUp className="h-4 w-4" />
<span className="text-sm text-[var(--text-secondary)]"></span>
</button>
{/* 回到底部 */}
<button
onClick={() => scrollToBottom('smooth')}
className="flex items-center gap-2 px-4 py-2 rounded-full
bg-[var(--bg-tertiary)]/90 backdrop-blur-md
border border-[var(--border-color)]
text-[var(--text-muted)] hover:text-[var(--accent-cyan)]
hover:border-[var(--accent-cyan)]/30
hover:shadow-[0_0_20px_var(--shadow-glow-sm)]
transition-all duration-300 ease-out
animate-fade-in"
aria-label="回到底部"
>
<ArrowDown className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} />
{newMessageCount > 0 ? (
<span className="text-sm font-medium text-[var(--text-primary)]">
{newMessageCount}
</span>
) : (
<span className="text-sm text-[var(--text-secondary)]"></span>
)}
{newMessageCount > 0 && (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--accent-cyan)]/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[var(--accent-cyan)]" />
</span>
)}
</button>
</div>
)}
</div>
)
}