Files
PiAgentUI/src/features/chat/ChatArea.tsx
T
Evandro b3489e1ede
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
2026-06-01 00:50:06 -03:00

623 lines
22 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.
/* eslint-disable react-refresh/only-export-components -- exports shared helper with component */
// ============================================
// ChatArea - 聊天消息显示区域
// ============================================
//
// Normal column layout keeps DOM order and visual order aligned.
// - scrollTop=max is bottom
// - new messages append at the visual bottom
// - a small auto-scroll effect follows streaming only while already at bottom
// - IntersectionObserver on the top sentinel triggers history loading
import {
useRef,
useImperativeHandle,
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { useTranslation } from 'react-i18next'
import { animate } from 'motion/mini'
import { MessageRenderer } from '../message'
import { messageStore } from '../../store'
import { useTheme } from '../../hooks/useTheme'
import type { Message } from '../../types/message'
import { RetryStatusInline, type RetryStatusInlineData } from './RetryStatusInline'
import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
import { AT_BOTTOM_THRESHOLD_PX } from '../../constants'
import { useChatViewport } from './chatViewport'
const MESSAGE_RENDER_ROOT_MARGIN = '150% 0px'
const STICKY_RENDER_MESSAGE_COUNT = 8
const ESTIMATED_USER_MESSAGE_HEIGHT = 72
const ESTIMATED_ASSISTANT_MESSAGE_HEIGHT = 160
const MAX_MEASURED_MESSAGE_HEIGHT_CACHE = 2000
const measuredMessageHeightCache = new Map<string, number>()
/** Stable no-op to avoid creating a new closure on every render. */
const NOOP = () => {}
function estimateMessageHeight(message: Message): number {
if (message.info.role === 'user') {
return Math.max(ESTIMATED_USER_MESSAGE_HEIGHT, message.parts.length * 40)
}
return Math.max(ESTIMATED_ASSISTANT_MESSAGE_HEIGHT, message.parts.length * 80)
}
function rememberMeasuredMessageHeight(cacheKey: string, height: number) {
measuredMessageHeightCache.set(cacheKey, height)
if (measuredMessageHeightCache.size <= MAX_MEASURED_MESSAGE_HEIGHT_CACHE) return
const oldestKey = measuredMessageHeightCache.keys().next().value
if (oldestKey) measuredMessageHeightCache.delete(oldestKey)
}
export function buildTurnDurationMap(messages: Message[], visibleMessages: Message[]): Map<string, number> {
const map = new Map<string, number>()
const visibleAssistantIds = new Set(
visibleMessages.filter(message => message.info.role === 'assistant').map(message => message.info.id),
)
let currentUserCreated: number | null = null
let currentVisibleAssistantId: string | null = null
let currentLastCompleted: number | null = null
const commitTurn = () => {
if (currentUserCreated == null || currentVisibleAssistantId == null || currentLastCompleted == null) return
map.set(currentVisibleAssistantId, currentLastCompleted - currentUserCreated)
}
for (const message of messages) {
if (message.info.role === 'user') {
commitTurn()
currentUserCreated = message.info.time.created
currentVisibleAssistantId = null
currentLastCompleted = null
continue
}
if (currentUserCreated == null || message.info.role !== 'assistant') continue
if (visibleAssistantIds.has(message.info.id)) {
currentVisibleAssistantId = message.info.id
}
if (message.info.time.completed != null) {
currentLastCompleted = message.info.time.completed
}
}
commitTurn()
return map
}
interface ChatAreaProps {
messages: Message[]
sessionId?: string | null
isStreaming?: boolean
allowStreamingLayoutAnimation?: boolean
loadState?: 'idle' | 'loading' | 'loaded' | 'error'
hasMoreHistory?: boolean
onLoadMore?: () => void | Promise<void>
onUndo?: (userMessageId: string) => void
onFork?: (message: Message, forkMessageId?: string) => void | Promise<void>
canUndo?: boolean
registerMessage?: (id: string, element: HTMLElement | null) => void
retryStatus?: RetryStatusInlineData | null
bottomPadding?: number
onVisibleMessageIdsChange?: (ids: string[]) => void
onAtBottomChange?: (atBottom: boolean) => void
}
export type ChatAreaHandle = {
scrollToBottom: (instant?: boolean) => void
scrollToBottomIfAtBottom: () => void
scrollToLastMessage: () => void
scrollToMessageIndex: (index: number) => void
scrollToMessageId: (messageId: string) => void
}
export const ChatArea = memo(
forwardRef<ChatAreaHandle, ChatAreaProps>(
(
{
messages,
sessionId,
isStreaming: _isStreaming = false,
allowStreamingLayoutAnimation = true,
loadState = 'idle',
onLoadMore,
onUndo,
onFork,
canUndo,
hasMoreHistory: _hasMoreHistory = false,
registerMessage,
retryStatus = null,
bottomPadding = 0,
onVisibleMessageIdsChange,
onAtBottomChange,
},
ref,
) => {
// ---- Refs ----
const { t } = useTranslation('chat')
const scrollRef = useRef<HTMLDivElement>(null)
const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null)
const topSentinelRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true)
const loadMoreRef = useRef(onLoadMore)
useEffect(() => {
loadMoreRef.current = onLoadMore
}, [onLoadMore])
const isLoadingRef = useRef(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
// Guard: 防止 session 初始加载时 sentinel 在视口内立即触发 loadMore。
// 只有用户主动滚离底部后解除。
const loadMoreBlockedRef = useRef(true)
const { isWideMode } = useTheme()
const { presentation } = useChatViewport()
const atBottomThreshold = presentation.isCompact ? 150 : AT_BOTTOM_THRESHOLD_PX
const messagePaddingClass = presentation.isCompact ? 'px-3' : 'px-5'
// ---- Data ----
const visibleMessageEntries = useMemo(() => buildVisibleMessageEntries(messages), [messages])
const visibleMessages = useMemo(() => visibleMessageEntries.map(e => e.message), [visibleMessageEntries])
const forkTargetIdMap = useMemo(
() =>
new Map(visibleMessageEntries.map(entry => [entry.message.info.id, getVisibleMessageForkTargetId(entry)])),
[visibleMessageEntries],
)
const turnDurationMap = useMemo(
() => buildTurnDurationMap(messages, visibleMessages),
[messages, visibleMessages],
)
const messageMaxWidthClass = isWideMode ? 'max-w-[95%] xl:max-w-6xl' : 'max-w-2xl'
const heightCacheScope = `${presentation.surfaceVariant}:${isWideMode ? 'wide' : 'normal'}`
const stickyRenderIds = useMemo(
() => new Set(visibleMessages.slice(-STICKY_RENDER_MESSAGE_COUNT).map(message => message.info.id)),
[visibleMessages],
)
const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => {
scrollRef.current = node
setScrollRoot(prev => (prev === node ? prev : node))
}, [])
// ============================================
// Scroll: isAtBottom tracking
// ============================================
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onScroll = () => {
const hasOverflow = el.scrollHeight > el.clientHeight + 1
const distFromBottom = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop)
const atBottom = !hasOverflow || distFromBottom <= atBottomThreshold
const prev = isAtBottomRef.current
isAtBottomRef.current = atBottom
if (prev !== atBottom) onAtBottomChange?.(atBottom)
// 用户滚离底部 → 解除 loadMore guard
if (!atBottom) loadMoreBlockedRef.current = false
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
}, [atBottomThreshold, onAtBottomChange])
// Follow new content while the user is already at the bottom.
useEffect(() => {
requestAnimationFrame(() => {
const el = scrollRef.current
if (!el || !isAtBottomRef.current) return
el.scrollTop = el.scrollHeight
})
}, [visibleMessages, retryStatus, bottomPadding])
// ============================================
// Session switch: snap to bottom
// ============================================
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
if (sessionId === prevSessionIdRef.current) return
prevSessionIdRef.current = sessionId
isAtBottomRef.current = true
loadMoreBlockedRef.current = true // 重置 guard
onAtBottomChange?.(true)
requestAnimationFrame(() => {
const el = scrollRef.current
if (!el) return
el.scrollTop = el.scrollHeight
// 消息列表整体淡入 — 一次命令式 animate,零 React 开销
animate(el, { opacity: [0, 1] }, { duration: 0.2, ease: 'easeOut' })
})
}, [sessionId, onAtBottomChange])
// 加载完成后 snap to bottom
useEffect(() => {
if (loadState !== 'loaded') return
requestAnimationFrame(() => {
const el = scrollRef.current
if (el && isAtBottomRef.current) el.scrollTop = el.scrollHeight
})
}, [loadState])
// ============================================
// Load more: IntersectionObserver on top sentinel
// ============================================
// Normal column layout requires scroll compensation after prepending history.
useEffect(() => {
const sentinel = topSentinelRef.current
const root = scrollRef.current
if (!sentinel || !root) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting || isLoadingRef.current) return
if (loadMoreBlockedRef.current) return
const fn = loadMoreRef.current
if (!fn) return
const sid = sessionId
if (!sid) return
const hasMore = messageStore.getSessionState(sid)?.hasMoreHistory ?? false
if (!hasMore) return
isLoadingRef.current = true
setIsLoadingMore(true)
const prevScrollHeight = root.scrollHeight
Promise.resolve(fn()).finally(() => {
requestAnimationFrame(() => {
root.scrollTop += root.scrollHeight - prevScrollHeight
})
isLoadingRef.current = false
setIsLoadingMore(false)
})
},
{ root, rootMargin: '200px 0px 0px 0px' },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [sessionId, visibleMessages])
// History prepend is compensated in the load-more callback above.
// ============================================
// Visible message tracking (for outline)
// ============================================
const onVisibleIdsChangeRef = useRef(onVisibleMessageIdsChange)
useEffect(() => {
onVisibleIdsChangeRef.current = onVisibleMessageIdsChange
}, [onVisibleMessageIdsChange])
useEffect(() => {
const root = scrollRef.current
if (!root) return
const visibleIds = new Set<string>()
const observer = new IntersectionObserver(
entries => {
let changed = false
for (const entry of entries) {
const id = entry.target.getAttribute('data-message-id')
if (!id) continue
if (entry.isIntersecting) {
if (!visibleIds.has(id)) {
visibleIds.add(id)
changed = true
}
} else if (visibleIds.has(id)) {
visibleIds.delete(id)
changed = true
}
}
if (changed) {
onVisibleIdsChangeRef.current?.(Array.from(visibleIds))
}
},
{ root, rootMargin: '100% 0px' },
)
// Observe all current message elements
const elements = root.querySelectorAll<HTMLElement>('[data-message-id]')
elements.forEach(el => observer.observe(el))
return () => observer.disconnect()
}, [visibleMessages])
// ============================================
// Imperative Handle
// ============================================
useImperativeHandle(
ref,
() => ({
scrollToBottom: (instant = false) => {
const el = scrollRef.current
if (!el) return
el.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' })
},
scrollToBottomIfAtBottom: () => {
const el = scrollRef.current
if (!el) return
// 自动跟随使用严格贴底判定,避免用户刚开始向上滚时还在宽松阈值内被抢回去。
const distFromBottom = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop)
if (distFromBottom > 2) return
el.scrollTop = el.scrollHeight
},
scrollToLastMessage: () => {
if (visibleMessages.length === 0) return
const lastId = visibleMessages[visibleMessages.length - 1].info.id
scrollRef.current
?.querySelector(`[data-message-id="${lastId}"]`)
?.scrollIntoView({ block: 'end', behavior: 'auto' })
},
scrollToMessageIndex: (index: number) => {
const msg = visibleMessages[index]
if (!msg) return
scrollRef.current
?.querySelector(`[data-message-id="${msg.info.id}"]`)
?.scrollIntoView({ block: 'start', behavior: 'smooth' })
},
scrollToMessageId: (messageId: string) => {
scrollRef.current
?.querySelector(`[data-message-id="${messageId}"]`)
?.scrollIntoView({ block: 'start', behavior: 'smooth' })
},
}),
[visibleMessages],
)
// ============================================
// Render
// ============================================
// 将连续助手消息分组,共享容器渲染(浑然一体)
const messageGroups = useMemo(() => {
const groups: Message[][] = []
for (const msg of visibleMessages) {
const prev = groups[groups.length - 1]
if (prev && msg.info.role === 'assistant' && prev[0].info.role === 'assistant') {
prev.push(msg)
} else {
groups.push([msg])
}
}
return groups
}, [visibleMessages])
const renderMessageGroup = useCallback(
(messages: Message[]) => {
const isUser = messages[0].info.role === 'user'
return (
<div
className={`w-full ${messageMaxWidthClass} mx-auto ${messagePaddingClass} py-3 transition-[max-width] duration-300 ease-in-out`}
>
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`min-w-0 group ${!isUser ? 'w-full' : ''} flex flex-col gap-2`}>
{messages.map(msg => (
<ViewportMessageItem
key={msg.info.id}
messageId={msg.info.id}
scrollRoot={scrollRoot}
registerMessage={registerMessage}
forceRender={msg.isStreaming || stickyRenderIds.has(msg.info.id)}
estimatedHeight={estimateMessageHeight(msg)}
heightCacheKey={`${heightCacheScope}:${msg.info.id}`}
>
<MessageRenderer
message={msg}
allowStreamingLayoutAnimation={allowStreamingLayoutAnimation}
turnDuration={turnDurationMap.get(msg.info.id)}
onUndo={onUndo}
onFork={onFork}
forkMessageId={forkTargetIdMap.get(msg.info.id)}
canUndo={canUndo}
onEnsureParts={NOOP}
/>
</ViewportMessageItem>
))}
</div>
</div>
</div>
)
},
[
scrollRoot,
heightCacheScope,
stickyRenderIds,
registerMessage,
onUndo,
onFork,
canUndo,
messageMaxWidthClass,
messagePaddingClass,
turnDurationMap,
forkTargetIdMap,
allowStreamingLayoutAnimation,
],
)
return (
<div className="h-full overflow-hidden contain-strict relative">
{/* Session loading spinner — 延迟 150ms 显示,快速加载时不闪烁 */}
{loadState === 'loading' && visibleMessages.length === 0 && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-text-400 session-loading-indicator">
<span className="w-5 h-5 border-2 border-text-400/30 border-t-text-400 rounded-full animate-spin" />
<span className="text-[length:var(--fs-base)]">{t('chatArea.loadingSession')}</span>
</div>
</div>
)}
<div
ref={setScrollContainerRef}
className="h-full overflow-y-auto overflow-x-hidden custom-scrollbar contain-content flex flex-col"
>
{/* Top sentinel for loadMore (视觉最顶部) */}
<div ref={topSentinelRef} className="h-px shrink-0" aria-hidden="true" />
{/* Top spacing (视觉顶部) */}
<div className="h-20 shrink-0" />
{/* Loading more indicator (视觉顶部附近) */}
{visibleMessages.length > 0 && isLoadingMore && (
<div className="flex justify-center py-3 shrink-0">
<div className="flex items-center gap-2 text-text-400 text-[length:var(--fs-sm)]">
<span className="w-3.5 h-3.5 border-2 border-text-400/30 border-t-text-400 rounded-full animate-spin" />
{t('chatArea.loadingHistory')}
</div>
</div>
)}
{/* Shim: flex-1 占满剩余空间,消息不满一屏时推到视觉底部 */}
<div className="flex-1" />
{/* Messages */}
{messageGroups.map(group => {
const first = group[0]
return (
<div key={first.info.id} className="shrink-0">
{renderMessageGroup(group)}
</div>
)
})}
{/* Retry status */}
{retryStatus && (
<div className={`w-full ${messageMaxWidthClass} mx-auto ${messagePaddingClass} shrink-0`}>
<div className="flex justify-start">
<div className="w-full min-w-0">
<RetryStatusInline status={retryStatus} />
</div>
</div>
</div>
)}
{/* Bottom spacing (视觉底部):
* 预留一块高 = inputBoxHeight + GAP 的空区,让最后一条消息不被贴底的 InputBox 遮住。
* GAP 控制消息底部 (fork/copy 按钮一行) 到输入胶囊顶部的空白,
* 保持偏宽松的留白,避免最后一条消息贴近输入区。 */}
<div
className="shrink-0"
style={{
height: bottomPadding > 0 ? `${bottomPadding + 48}px` : '256px',
}}
/>
</div>
</div>
)
},
),
)
interface ViewportMessageItemProps {
messageId: string
scrollRoot: HTMLDivElement | null
forceRender?: boolean
estimatedHeight: number
heightCacheKey: string
registerMessage?: (id: string, element: HTMLElement | null) => void
children: ReactNode
}
const ViewportMessageItem = memo(function ViewportMessageItem({
messageId,
scrollRoot,
forceRender = false,
estimatedHeight,
heightCacheKey,
registerMessage,
children,
}: ViewportMessageItemProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const [isNearViewport, setIsNearViewport] = useState(() => forceRender || typeof IntersectionObserver === 'undefined')
const [measuredHeightState, setMeasuredHeightState] = useState(() => ({
cacheKey: heightCacheKey,
height: measuredMessageHeightCache.get(heightCacheKey) ?? null,
}))
const measuredHeight =
measuredHeightState.cacheKey === heightCacheKey
? measuredHeightState.height
: (measuredMessageHeightCache.get(heightCacheKey) ?? null)
const setWrapperElement = useCallback(
(node: HTMLDivElement | null) => {
wrapperRef.current = node
registerMessage?.(messageId, node)
},
[messageId, registerMessage],
)
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper || !scrollRoot) return
if (typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
([entry]) => {
setIsNearViewport(prev => (prev === entry.isIntersecting ? prev : entry.isIntersecting))
},
{
root: scrollRoot,
rootMargin: MESSAGE_RENDER_ROOT_MARGIN,
},
)
observer.observe(wrapper)
return () => observer.disconnect()
}, [scrollRoot])
const shouldRender = forceRender || isNearViewport
useEffect(() => {
const content = contentRef.current
if (!content || !shouldRender || typeof ResizeObserver === 'undefined') return
const updateHeight = () => {
const nextHeight = content.offsetHeight
if (nextHeight <= 0) return
setMeasuredHeightState(prev => {
if (prev.cacheKey === heightCacheKey && prev.height !== null && Math.abs(prev.height - nextHeight) < 1)
return prev
rememberMeasuredMessageHeight(heightCacheKey, nextHeight)
return { cacheKey: heightCacheKey, height: nextHeight }
})
}
updateHeight()
const observer = new ResizeObserver(updateHeight)
observer.observe(content)
return () => observer.disconnect()
}, [heightCacheKey, shouldRender])
return (
<div ref={setWrapperElement} data-message-id={messageId}>
{shouldRender ? (
<div ref={contentRef}>{children}</div>
) : (
<div aria-hidden="true" style={{ height: `${measuredHeight ?? estimatedHeight}px` }} />
)}
</div>
)
})