import React from 'react'; import { RiArrowDownLine } from '@remixicon/react'; import { useShallow } from 'zustand/react/shallow'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList from './MessageList'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useDeviceInfo } from '@/lib/device'; import { getMemoryLimits } from '@/stores/types/sessionTypes'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { cn } from '@/lib/utils'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const collectVisibleSessionIdsForBlockingRequests = ( sessions: Array<{ id: string; parentID?: string }> | undefined, currentSessionId: string | null ): string[] => { if (!currentSessionId) return []; if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId]; const current = sessions.find((session) => session.id === currentSessionId); if (!current) return [currentSessionId]; // Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread. if (current.parentID) { return []; } const childIds = sessions .filter((session) => session.parentID === currentSessionId) .map((session) => session.id); return [currentSessionId, ...childIds]; }; const flattenBlockingRequests = ( source: Map, sessionIds: string[] ): T[] => { if (sessionIds.length === 0) return []; const seen = new Set(); const result: T[] = []; for (const sessionId of sessionIds) { const entries = source.get(sessionId); if (!entries || entries.length === 0) continue; for (const entry of entries) { if (seen.has(entry.id)) continue; seen.add(entry.id); result.push(entry); } } return result; }; export const ChatContainer: React.FC = () => { const { currentSessionId, isLoading, loadMessages, loadMoreMessages, updateViewportAnchor, openNewSessionDraft, trimToViewportWindow, newSessionDraft, } = useSessionStore( useShallow((state) => ({ currentSessionId: state.currentSessionId, isLoading: state.isLoading, loadMessages: state.loadMessages, loadMoreMessages: state.loadMoreMessages, updateViewportAnchor: state.updateViewportAnchor, openNewSessionDraft: state.openNewSessionDraft, trimToViewportWindow: state.trimToViewportWindow, newSessionDraft: state.newSessionDraft, })) ); const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore( useShallow((state) => ({ isSyncing: state.isSyncing, messageStreamStates: state.messageStreamStates, sessionMemoryStateMap: state.sessionMemoryState, })) ); const { isTimelineDialogOpen, setTimelineDialogOpen, isExpandedInput, } = useUIStore(); const sessionMessages = useSessionStore( React.useCallback( (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [currentSessionId] ) ); const blockingRequestState = useSessionStore( useShallow((state) => ({ sessions: state.sessions, permissions: state.permissions, questions: state.questions, })) ); const scopedSessionIds = React.useMemo( () => collectVisibleSessionIdsForBlockingRequests( blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })), currentSessionId, ), [blockingRequestState.sessions, currentSessionId] ); const sessionPermissions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds); }, [blockingRequestState.permissions, scopedSessionIds]); const sessionQuestions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds); }, [blockingRequestState.questions, scopedSessionIds]); const memoryState = useSessionStore( React.useCallback( (state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null), [currentSessionId] ) ); const streamingMessageId = useSessionStore( React.useCallback( (state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null), [currentSessionId] ) ); const sessionStatusForCurrent = useSessionStore( React.useCallback( (state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS), [currentSessionId] ) ); const hasSessionMessagesEntry = useSessionStore( React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId]) ); const { isMobile } = useDeviceInfo(); const draftOpen = Boolean(newSessionDraft?.open); const isDesktopExpandedInput = isExpandedInput && !isMobile; React.useEffect(() => { if (!currentSessionId && !draftOpen) { openNewSessionDraft(); } }, [currentSessionId, draftOpen, openNewSessionDraft]); const [turnStart, setTurnStart] = React.useState(0); const turnHandleRef = React.useRef(null); const turnIdleRef = React.useRef(false); const TURN_INIT = 20; const TURN_BATCH = 20; const userTurnIndexes = React.useMemo(() => { const indexes: number[] = []; for (let i = 0; i < sessionMessages.length; i += 1) { const message = sessionMessages[i]; const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role; if (role === 'user') { indexes.push(i); } } return indexes; }, [sessionMessages]); const cancelTurnBackfill = React.useCallback(() => { const handle = turnHandleRef.current; if (handle === null) { return; } turnHandleRef.current = null; if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { window.cancelIdleCallback(handle); return; } if (typeof window !== 'undefined') { window.clearTimeout(handle); } }, []); const renderedSessionMessages = React.useMemo(() => { if (turnStart <= 0 || userTurnIndexes.length === 0) { return sessionMessages; } const startIndex = userTurnIndexes[turnStart] ?? 0; return sessionMessages.slice(startIndex); }, [sessionMessages, turnStart, userTurnIndexes]); const backfillTurns = React.useCallback(() => { if (turnStart <= 0) { return; } const container = typeof document !== 'undefined' ? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null) : null; const beforeTop = container?.scrollTop ?? null; const beforeHeight = container?.scrollHeight ?? null; setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0)); if (container && beforeTop !== null && beforeHeight !== null) { window.requestAnimationFrame(() => { const delta = container.scrollHeight - beforeHeight; if (delta !== 0) { container.scrollTop = beforeTop + delta; } }); } }, [turnStart]); const scheduleTurnBackfill = React.useCallback(() => { if (turnHandleRef.current !== null || turnStart <= 0) { return; } if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { turnIdleRef.current = true; turnHandleRef.current = window.requestIdleCallback(() => { turnHandleRef.current = null; backfillTurns(); }); return; } turnIdleRef.current = false; turnHandleRef.current = window.setTimeout(() => { turnHandleRef.current = null; backfillTurns(); }, 0); }, [backfillTurns, turnStart]); const sessionBlockingCards = React.useMemo(() => { return [...sessionPermissions, ...sessionQuestions]; }, [sessionPermissions, sessionQuestions]); const { scrollRef, handleMessageContentChange, getAnimationHandlers, showScrollButton, scrollToBottom, scrollToPosition, isPinned, } = useChatScrollManager({ currentSessionId, sessionMessages: renderedSessionMessages, streamingMessageId, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, isSyncing, isMobile, messageStreamStates, sessionPermissions: sessionBlockingCards, trimToViewportWindow, }); React.useEffect(() => { cancelTurnBackfill(); if (!currentSessionId) { setTurnStart(0); return; } const turnCount = userTurnIndexes.length; const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0; setTurnStart(start); }, [cancelTurnBackfill, currentSessionId, userTurnIndexes.length]); React.useEffect(() => { scheduleTurnBackfill(); return () => { cancelTurnBackfill(); }; }, [cancelTurnBackfill, scheduleTurnBackfill, turnStart]); const hasMoreAbove = React.useMemo(() => { if (!memoryState) { return false; } if (memoryState.historyComplete === true) { return false; } if (memoryState.hasMoreAbove) { return true; } if (memoryState.historyComplete === false) { return true; } // Backward compatibility: older persisted sessions may miss history flags. if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) { return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES; } return false; }, [memoryState, sessionMessages.length]); const [isLoadingOlder, setIsLoadingOlder] = React.useState(false); React.useEffect(() => { setIsLoadingOlder(false); }, [currentSessionId]); const handleLoadOlder = React.useCallback(async () => { if (!currentSessionId || isLoadingOlder) { return; } cancelTurnBackfill(); setTurnStart(0); const container = scrollRef.current; const prevHeight = container?.scrollHeight ?? null; const prevTop = container?.scrollTop ?? null; setIsLoadingOlder(true); try { await loadMoreMessages(currentSessionId, 'up'); if (container && prevHeight !== null && prevTop !== null) { const heightDiff = container.scrollHeight - prevHeight; scrollToPosition(prevTop + heightDiff, { instant: true }); } } finally { setIsLoadingOlder(false); } }, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]); const handleRenderEarlier = React.useCallback(() => { cancelTurnBackfill(); setTurnStart(0); }, [cancelTurnBackfill]); // Scroll to a specific message by ID (for timeline dialog) const scrollToMessage = React.useCallback((messageId: string) => { const container = scrollRef.current; if (!container) return; // Find the message element by looking for data-message-id attribute const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement; if (messageElement) { // Scroll to the message with some padding (50px from top) const containerRect = container.getBoundingClientRect(); const messageRect = messageElement.getBoundingClientRect(); const offset = 50; const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset; container.scrollTo({ top: scrollTop, behavior: 'smooth' }); } }, [scrollRef]); React.useEffect(() => { if (!currentSessionId) { return; } const hasSessionMessages = hasSessionMessagesEntry; if (hasSessionMessages) { return; } const load = async () => { try { await loadMessages(currentSessionId); } finally { const statusType = sessionStatusForCurrent.type ?? 'idle'; const isActivePhase = statusType === 'busy' || statusType === 'retry'; // When pinned and active, scroll is already maintained automatically const shouldSkipScroll = isActivePhase && isPinned; if (!shouldSkipScroll) { if (typeof window === 'undefined') { scrollToBottom({ instant: true }); } else { window.requestAnimationFrame(() => { scrollToBottom({ instant: true }); }); } } } }; void load(); }, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]); if (!currentSessionId && !draftOpen) { return (
); } if (!currentSessionId && draftOpen) { return (
{!isDesktopExpandedInput ? (
) : null}
); } if (!currentSessionId) { return null; } if (isLoading && sessionMessages.length === 0 && !streamingMessageId) { const hasMessagesEntry = hasSessionMessagesEntry; if (!hasMessagesEntry) { return (
{[1, 2, 3].map((i) => (
))}
); } } if (sessionMessages.length === 0 && !streamingMessageId) { return (
{!isDesktopExpandedInput ? (
) : null}
); } return (
0} onRenderEarlier={handleRenderEarlier} scrollToBottom={scrollToBottom} />
{!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && (
)}
); };