Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { MessageNavigation } from './MessageNavigation'
import { useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
Expand Down Expand Up @@ -505,6 +506,7 @@ function SelectableChatMessage({
<div
ref={rootRef}
data-chat-selectable-message={role}
data-message-id={messageId}
onPointerDown={(event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
lastSelectionPointerRef.current = getSelectionPointer(event)
Expand Down Expand Up @@ -1615,6 +1617,38 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
scrollToBottom('auto')
}, [scrollToBottom])

const handleNavigateToMessage = useCallback((messageId: string) => {
const scrollToElement = () => {
const el = document.querySelector(`[data-message-id="${messageId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return true
}
return false
}

if (scrollToElement()) return

const container = scrollContainerRef.current
if (!container) return

const messageIndex = messages.findIndex((m) => m.id === messageId)
if (messageIndex === -1) return

const totalMessages = messages.length
if (totalMessages === 0) return

const scrollHeight = container.scrollHeight - container.clientHeight
const estimatedPosition = (messageIndex / totalMessages) * scrollHeight
container.scrollTop = Math.max(0, estimatedPosition - container.clientHeight / 3)

requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToElement()
})
})
}, [messages])

useEffect(() => {
const content = scrollContentRef.current
if (!content || typeof ResizeObserver === 'undefined') return
Expand Down Expand Up @@ -2046,6 +2080,11 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
</button>
)}

<MessageNavigation
messages={messages}
onNavigate={handleNavigateToMessage}
/>

<ConfirmDialog
open={Boolean(confirmTurnCard)}
onClose={() => {
Expand Down
66 changes: 66 additions & 0 deletions desktop/src/components/chat/MessageNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { memo, useMemo } from 'react'
import { User } from 'lucide-react'
import type { UIMessage } from '../../types/chat'

type MessageNavigationProps = {
messages: UIMessage[]
onNavigate: (messageId: string) => void
}

type NavigationItem = {
id: string
label: string
}

function getNavigationLabel(message: UIMessage): string {
if (message.type === 'user_text') {
const text = message.content.trim()
return text.length > 30 ? text.slice(0, 30) + '...' : text || '用户消息'
}
return '消息'
}

export const MessageNavigation = memo(function MessageNavigation({
messages,
onNavigate,
}: MessageNavigationProps) {
const navItems = useMemo(() => {
const items: NavigationItem[] = []
for (const message of messages) {
if (message.type === 'user_text') {
items.push({
id: message.id,
label: getNavigationLabel(message),
})
}
}
return items
}, [messages])

if (navItems.length === 0) {
return null
}

return (
<div className="absolute right-0 top-0 bottom-0 w-[220px] overflow-y-auto py-4 pl-2 pr-4">
<div className="sticky top-0 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2 shadow-sm">
<div className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">
消息导航
</div>
<div className="flex flex-col gap-0.5">
{navItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onNavigate(item.id)}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<User size={12} strokeWidth={2} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate">{item.label}</span>
</button>
))}
</div>
</div>
</div>
)
})
Loading