🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
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
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
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
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
@@ -0,0 +1,622 @@
|
||||
/* 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>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user