import React from 'react'; import { RiArrowLeftLine } from '@remixicon/react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; import { ChatInput } from './ChatInput'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList, { type MessageListHandle } from './MessageList'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useTimelineStaging } from '@/hooks/useTimelineStaging'; import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { cn } from '@/lib/utils'; import { collectVisibleSessionIdsForBlockingRequests, flattenBlockingRequests, } from './lib/blockingRequests'; // New sync system imports import { useSessionUIStore } from '@/sync/session-ui-store'; import { useViewportStore } from '@/sync/viewport-store'; import { useStreamingStore } from '@/sync/streaming'; import { useSessionMessageRecords, useSessions, useDirectorySync, useSessionStatus, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { getAllSyncSessions } from '@/sync/sync-refs'; 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 SESSION_RESELECTED_EVENT = 'openchamber:session-reselected'; type HydratingToolSkeletonRow = { id: string; titleWidth: string; detailWidth: string; }; const HYDRATING_SKELETON_ITEMS: Array<{ id: number; toolRows: HydratingToolSkeletonRow[]; textWidths: [string, string, string]; }> = [ { id: 1, toolRows: [ { id: 'search', titleWidth: 'w-24', detailWidth: 'w-52' }, { id: 'read', titleWidth: 'w-20', detailWidth: 'w-36' }, { id: 'edit', titleWidth: 'w-24', detailWidth: 'w-64' }, ], textWidths: ['w-24', 'w-[92%]', 'w-[78%]'], }, { id: 2, toolRows: [ { id: 'read', titleWidth: 'w-20', detailWidth: 'w-40' }, { id: 'search', titleWidth: 'w-24', detailWidth: 'w-48' }, ], textWidths: ['w-20', 'w-[88%]', 'w-[70%]'], }, { id: 3, toolRows: [ { id: 'shell', titleWidth: 'w-28', detailWidth: 'w-44' }, { id: 'edit', titleWidth: 'w-24', detailWidth: 'w-56' }, ], textWidths: ['w-24', 'w-[84%]', 'w-[64%]'], }, ]; export const ChatContainer: React.FC = () => { // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor); const isSyncing = useViewportStore((s) => s.isSyncing); const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState); // Sync actions const sync = useSync(); const loadMessages = React.useCallback( (sessionId: string) => sync.syncSession(sessionId), [sync], ); const loadMoreMessages = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId), [sync], ); // UI store const isExpandedInput = useUIStore((state) => state.isExpandedInput); const stickyUserHeader = useUIStore((state) => state.stickyUserHeader); const chatRenderMode = useUIStore((state) => state.chatRenderMode); // Streaming state const streamingMessageId = useStreamingStore( React.useCallback( (s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null), [currentSessionId], ), ); // Messages from sync system const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; // Sessions from sync system const sessions = useSessions(); // Session status from sync system const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; // Permissions & questions from sync system const allPermissions = useDirectorySync( React.useCallback((s) => s.permission ?? {}, []), ); const allQuestions = useDirectorySync( React.useCallback((s) => s.question ?? {}, []), ); // Convert Record → Map for blockingRequests helpers const permissionsMap = React.useMemo(() => { const m = new Map(); for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]); return m; }, [allPermissions]); const questionsMap = React.useMemo(() => { const m = new Map(); for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]); return m; }, [allQuestions]); const scopedSessionIds = React.useMemo( () => collectVisibleSessionIdsForBlockingRequests( sessions.map((session) => ({ id: session.id, parentID: session.parentID })), currentSessionId, ), [sessions, currentSessionId], ); const sessionPermissions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS; return flattenBlockingRequests(permissionsMap, scopedSessionIds); }, [permissionsMap, scopedSessionIds]); const sessionQuestions = React.useMemo(() => { if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; return flattenBlockingRequests(questionsMap, scopedSessionIds); }, [questionsMap, scopedSessionIds]); // History metadata — use sync's hasMore/isLoading const historyMeta = React.useMemo(() => { if (!currentSessionId) return null; return { limit: sessionMessages.length, complete: !sync.hasMore(currentSessionId), loading: sync.isLoading(currentSessionId), }; }, [currentSessionId, sessionMessages.length, sync]); const hasSessionMessagesEntry = sessionMessages.length > 0 || (currentSessionId ? sync.hasMore(currentSessionId) : false); const { isMobile } = useDeviceInfo(); const draftOpen = Boolean(newSessionDraft?.open); const isDesktopExpandedInput = isExpandedInput && !isMobile; const messageListRef = React.useRef(null); const parentSession = React.useMemo(() => { if (!currentSessionId) return null; const current = sessions.find((session) => session.id === currentSessionId); const parentID = current?.parentID; if (!parentID) return null; return sessions.find((session) => session.id === parentID) ?? getAllSyncSessions().find((session) => session.id === parentID) ?? null; }, [currentSessionId, sessions]); const handleReturnToParentSession = React.useCallback(() => { if (!parentSession) return; const parentDirectory = (parentSession as Session & { directory?: string | null }).directory ?? null; setCurrentSession(parentSession.id, parentDirectory); }, [parentSession, setCurrentSession]); const returnToParentButton = parentSession ? ( ) : null; React.useEffect(() => { if (!currentSessionId && !draftOpen) { openNewSessionDraft(); } }, [currentSessionId, draftOpen, openNewSessionDraft]); const sessionBlockingCards = React.useMemo(() => { return [...sessionPermissions, ...sessionQuestions]; }, [sessionPermissions, sessionQuestions]); const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); const handleActiveTurnChange = React.useCallback((turnId: string | null) => { activeTurnChangeRef.current(turnId); }, []); const { scrollRef, handleMessageContentChange, getAnimationHandlers, scrollToBottom, isPinned, isOverflowing, isProgrammaticFollowActive, } = useChatScrollManager({ currentSessionId, sessionMessages, streamingMessageId, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, isSyncing, isMobile, chatRenderMode, sessionPermissions: sessionBlockingCards, onActiveTurnChange: handleActiveTurnChange, }); // Deferred timeline staging — renders 1 message on first paint, // adds 3 per rAF frame to avoid blocking. const { stagedMessages } = useTimelineStaging({ sessionKey: currentSessionId ?? '', messages: sessionMessages, }); const timelineController = useChatTimelineController({ sessionId: currentSessionId, messages: stagedMessages, historyMeta, scrollRef, messageListRef, loadMoreMessages, scrollToBottom, isPinned, isOverflowing, }); const { resumeToBottomInstant } = timelineController; React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); const navigation = useChatTurnNavigation({ sessionId: currentSessionId, turnIds: timelineController.turnIds, activeTurnId: timelineController.activeTurnId, scrollToTurn: timelineController.scrollToTurn, scrollToMessage: timelineController.scrollToMessage, resumeToBottom: timelineController.resumeToBottom, }); React.useEffect(() => { if (typeof window === 'undefined' || !currentSessionId) return; const handleSessionReselected = (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail !== currentSessionId) return; resumeToBottomInstant(); }; window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); return () => { window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); }; }, [currentSessionId, resumeToBottomInstant]); React.useLayoutEffect(() => { const container = scrollRef.current; if (!container) return; const updateChatScrollHeight = () => { container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`); }; updateChatScrollHeight(); let rafId = 0; const scheduleUpdate = () => { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; updateChatScrollHeight(); }); }; if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', scheduleUpdate); return () => { if (rafId) cancelAnimationFrame(rafId); window.removeEventListener('resize', scheduleUpdate); }; } const resizeObserver = new ResizeObserver(scheduleUpdate); resizeObserver.observe(container); return () => { if (rafId) cancelAnimationFrame(rafId); resizeObserver.disconnect(); }; }, [currentSessionId, isDesktopExpandedInput, scrollRef]); const hasHistoryMetadata = Boolean(historyMeta); const isSessionHydrating = Boolean(currentSessionId) && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); React.useEffect(() => { if (!currentSessionId) return; if (hasSessionMessagesEntry && hasHistoryMetadata) return; const load = async () => { await loadMessages(currentSessionId).finally(() => { const statusType = sessionStatusForCurrent.type ?? 'idle'; const isActivePhase = statusType === 'busy' || statusType === 'retry'; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget; if (!shouldSkipScroll) { if (typeof window === 'undefined') { scrollToBottom({ instant: true }); } else { window.requestAnimationFrame(() => { scrollToBottom({ instant: true }); }); } } }); }; void load(); }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]); if (!currentSessionId && !draftOpen) { return (
); } if (!currentSessionId && draftOpen) { return (
{!isDesktopExpandedInput ? (
) : null}
); } if (!currentSessionId) { return null; } if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) { return (
{returnToParentButton}
{HYDRATING_SKELETON_ITEMS.map((item) => (
{item.toolRows.map((row) => { return (
); })}
))}
); } if (sessionMessages.length === 0 && !streamingMessageId) { return (
{returnToParentButton}
{!isDesktopExpandedInput ? (
) : null}
); } return (
{returnToParentButton}
{ void timelineController.loadEarlier(); }} scrollToBottom={scrollToBottom} scrollRef={scrollRef} />
{!isDesktopExpandedInput && sessionMessages.length > 0 && ( )}
); };