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 { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import { StatusRowContainer } from './StatusRowContainer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } 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 { useSessionMessageCount, 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'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const; type SessionMessageRecord = { info: Message; parts: Part[] }; const getSessionMessageId = (message: SessionMessageRecord | undefined): string | null => { const id = message?.info?.id; return typeof id === 'string' && id.trim().length > 0 ? id : null; }; const canFreezeDetachedViewport = ( previous: SessionMessageRecord[], next: SessionMessageRecord[], streamingMessageId: string | null, ): boolean => { if (!streamingMessageId || previous.length === 0 || next.length === 0) { return false; } if (next.length < previous.length) { return false; } if (next.length === previous.length) { for (let index = 0; index < next.length - 1; index += 1) { if (previous[index] !== next[index]) { return false; } } return getSessionMessageId(previous[previous.length - 1]) === getSessionMessageId(next[next.length - 1]); } for (let index = 0; index < previous.length; index += 1) { if (previous[index] !== next[index]) { return false; } } return true; }; type HydratingToolSkeletonRow = { id: string; titleWidth: string; detailWidth: string; }; type ChatViewportProps = { currentSessionId: string; isDesktopExpandedInput: boolean; isMobile: boolean; stickyUserHeader: boolean; scrollRef: React.RefObject; messageListRef: React.RefObject; turnStart: number; pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; hasMoreAboveTurns: boolean; isLoadingOlder: boolean; sessionIsWorking: boolean; streamingMessageId: string | null; activeStreamingPhase: import('./message/types').StreamPhase | null; retryOverlay: { sessionId: string; message: string; confirmedAt?: number; fallbackTimestamp?: number; } | null; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; handleLoadOlder: () => void; scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; }; const ChatViewport = React.memo(({ currentSessionId, isDesktopExpandedInput, isMobile, stickyUserHeader, scrollRef, messageListRef, turnStart, pendingRevealWork, renderedMessages, hasMoreAboveTurns, isLoadingOlder, sessionIsWorking, streamingMessageId, activeStreamingPhase, retryOverlay, handleMessageContentChange, getAnimationHandlers, handleLoadOlder, scrollToBottom, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, }: ChatViewportProps) => { return (
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
{sessionQuestions.map((question) => ( ))} {sessionPermissions.map((permission) => ( ))}
)}
); }, (prev, next) => { return prev.currentSessionId === next.currentSessionId && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile && prev.stickyUserHeader === next.stickyUserHeader && prev.scrollRef === next.scrollRef && prev.messageListRef === next.messageListRef && prev.turnStart === next.turnStart && prev.pendingRevealWork === next.pendingRevealWork && prev.renderedMessages === next.renderedMessages && prev.hasMoreAboveTurns === next.hasMoreAboveTurns && prev.isLoadingOlder === next.isLoadingOlder && prev.sessionIsWorking === next.sessionIsWorking && prev.streamingMessageId === next.streamingMessageId && prev.activeStreamingPhase === next.activeStreamingPhase && prev.retryOverlay === next.retryOverlay && prev.handleMessageContentChange === next.handleMessageContentChange && prev.getAnimationHandlers === next.getAnimationHandlers && prev.handleLoadOlder === next.handleLoadOlder && prev.scrollToBottom === next.scrollToBottom && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive; }); ChatViewport.displayName = 'ChatViewport'; 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], ), ); const activeStreamingPhase = useStreamingStore( React.useCallback( (s) => { if (!streamingMessageId) return null; return s.messageStreamStates.get(streamingMessageId)?.phase ?? null; }, [streamingMessageId], ), ); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? ''); const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false); // Messages from sync system const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, { suspendPartUpdates: suspendDetachedTailUpdates, }); 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]); const sessionIsWorking = React.useMemo(() => { if (!currentSessionId || sessionPermissions.length > 0) { return false; } const statusType = sessionStatusForCurrent.type ?? 'idle'; if (statusType === 'busy' || statusType === 'retry') { return true; } const lastMessage = sessionMessages[sessionMessages.length - 1]?.info as Message | undefined; return Boolean( lastMessage && lastMessage.role === 'assistant' && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number', ); }, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]); const activeRetryStatus = React.useMemo(() => { if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') { return null; } const rawMessage = typeof (sessionStatusForCurrent as { message?: string }).message === 'string' ? (((sessionStatusForCurrent as { message?: string }).message) ?? '').trim() : ''; return { sessionId: currentSessionId, message: rawMessage || DEFAULT_RETRY_MESSAGE, confirmedAt: (sessionStatusForCurrent as { confirmedAt?: number }).confirmedAt, }; }, [currentSessionId, sessionStatusForCurrent]); const [retryFallbackTimestamp, setRetryFallbackTimestamp] = React.useState(0); const retryFallbackSessionRef = React.useRef(null); React.useEffect(() => { if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') { retryFallbackSessionRef.current = null; setRetryFallbackTimestamp(0); return; } if (retryFallbackSessionRef.current !== activeRetryStatus.sessionId) { retryFallbackSessionRef.current = activeRetryStatus.sessionId; setRetryFallbackTimestamp(Date.now()); } }, [activeRetryStatus]); const retryOverlay = React.useMemo(() => { if (!activeRetryStatus) { return null; } return { ...activeRetryStatus, fallbackTimestamp: retryFallbackTimestamp, }; }, [activeRetryStatus, retryFallbackTimestamp]); // 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, sessionMessageCount, sessionIsWorking, sessionMemoryState: sessionMemoryStateMap, updateViewportAnchor, isSyncing, isMobile, chatRenderMode, sessionPermissions: sessionBlockingCards, onActiveTurnChange: handleActiveTurnChange, }); React.useEffect(() => { const next = Boolean(currentSessionId && streamingMessageId && !isPinned); setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next)); }, [currentSessionId, isPinned, streamingMessageId]); const viewportMessagesRef = React.useRef(EMPTY_MESSAGES); const viewportSessionIdRef = React.useRef(null); const viewportMessages = React.useMemo(() => { if (viewportSessionIdRef.current !== currentSessionId) { viewportSessionIdRef.current = currentSessionId; viewportMessagesRef.current = sessionMessages; return sessionMessages; } const shouldFreezeViewport = Boolean( currentSessionId && streamingMessageId && !isPinned && historyMeta?.loading !== true && canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId), ); if (shouldFreezeViewport) { return viewportMessagesRef.current; } viewportMessagesRef.current = sessionMessages; return sessionMessages; }, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]); // Deferred timeline staging — renders 1 message on first paint, // adds 3 per rAF frame to avoid blocking. const { stagedMessages } = useTimelineStaging({ sessionKey: currentSessionId ?? '', messages: viewportMessages, }); const timelineController = useChatTimelineController({ sessionId: currentSessionId, messages: stagedMessages, historyMeta, scrollRef, messageListRef, loadMoreMessages, scrollToBottom, isPinned, isOverflowing, }); const { loadEarlier, resumeToBottomInstant } = timelineController; React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); React.useEffect(() => { if (sessionPermissions.length === 0 && sessionQuestions.length === 0) { return; } handleMessageContentChange('permission'); }, [handleMessageContentChange, sessionPermissions, sessionQuestions]); const handleLoadOlder = React.useCallback(() => { void loadEarlier(); }, [loadEarlier]); 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; if (isPinned || !isOverflowing || isProgrammaticFollowActive) return; resumeToBottomInstant(); }; window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); return () => { window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener); }; }, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, 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 lastHydratedSessionRef = React.useRef(null); const isSessionHydrating = Boolean(currentSessionId) && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true); React.useEffect(() => { if (!currentSessionId) return; if (hasSessionMessagesEntry && hasHistoryMetadata) return; const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId; lastHydratedSessionRef.current = currentSessionId; 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 = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch); 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}
{!isDesktopExpandedInput && sessionMessages.length > 0 && ( )}
); };