import React from 'react'; import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import { ChatInput } from './ChatInput'; import { DraftPresetChips } from './DraftPresetChips'; import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import { useGlobalSyncStore } from '@/sync/global-sync-store'; import MessageList, { type MessageListHandle } from './MessageList'; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery'; import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; import { useScrollShadow } from '@/components/ui/useScrollShadow'; import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useChatSurfaceMode } from './useChatSurfaceMode'; import { useDeviceInfo } from '@/lib/device'; import { Button } from '@/components/ui/button'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { Icon } from "@/components/icon/Icon"; import { cn, formatDirectoryName } from '@/lib/utils'; import { useProjectsStore } from '@/stores/useProjectsStore'; // New sync system imports import { useSessionUIStore } from '@/sync/session-ui-store'; import { useStreamingStore } from '@/sync/streaming'; import { useSessionMessageCount, useSessionMessageRecords, useSessionMessageLoadState, useSyncDirectory, useSessionRenderable, useSessionStatus, useScopedBlockingPermissions, useScopedBlockingQuestions, useParentSession, useSession, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isVSCodeRuntime } from '@/lib/desktop'; import { WorkStatusPanel } from './work-status/WorkStatusPanel'; import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility'; import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge'; import { resolveChatPromptReadOnly } from './chatPromptReadOnly'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance'; import { isChatDirectoryPath } from '@/lib/chatDirectories'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const DRAFT_EXIT_DURATION_MS = 120; const COMPOSER_MOVE_DURATION_MS = 180; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none', overscrollBehavior: 'contain', overscrollBehaviorY: 'contain', } as const; const CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR = [ 'a[href]', 'button', 'input', 'select', 'textarea', '[contenteditable="true"]', '[role="button"]', '[role="combobox"]', '[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="menuitem"]', '[role="option"]', '[role="textbox"]', '[data-radix-popper-content-wrapper]', ].join(','); type SessionMessageRecord = { info: Message; parts: Part[] }; const isHTMLElement = (target: EventTarget | null): target is HTMLElement => { return target instanceof HTMLElement; }; const shouldIgnoreChatNavigationTarget = (target: EventTarget | null): boolean => { if (!isHTMLElement(target)) { return false; } return Boolean(target.closest(CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR)); }; const shouldIgnoreChatNavigationForFocus = (activeElement: Element | null, scrollContainer: HTMLElement | null): boolean => { if (typeof document === 'undefined') { return true; } if (!activeElement || activeElement === document.body || activeElement === document.documentElement) { return true; } if (shouldIgnoreChatNavigationTarget(activeElement)) { return true; } return !scrollContainer?.contains(activeElement); }; const hasBlockingChatOverlay = (): boolean => { const { isAboutDialogOpen, isCommandPaletteOpen, isHelpDialogOpen, isImagePreviewOpen, isMultiRunLauncherOpen, isSessionSwitcherOpen, isSettingsDialogOpen, } = useUIStore.getState(); return isAboutDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isImagePreviewOpen || isMultiRunLauncherOpen || isSessionSwitcherOpen || isSettingsDialogOpen; }; type HydratingToolSkeletonRow = { id: string; titleWidth: string; detailWidth: string; }; type ChatViewportProps = { currentSessionId: string; currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; registerList: (list: TimelineListHandle | null) => void; anchorMessageId: string | null; onAnchorReady: (messageId: string, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: string) => void; onIsAtEndChange: (isAtEnd: boolean) => void; onTimelineDataChange: () => void; pendingRevealWork: boolean; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; sessionIsWorking: boolean; streamingMessageId: string | null; activeStreamingPhase: import('./message/types').StreamPhase | null; retryOverlay: { sessionId: string; message: string; confirmedAt?: number; fallbackTimestamp?: number; } | null; scrollToBottom: () => void; endPinningReleased: boolean; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; showLoadOlderButton: boolean; onLoadOlder: () => void; turnIds: string[]; activeTurnId: string | null; onSelectTurn: (turnId: string) => void; showPromptNavigator: boolean; canLoadEarlierPrompts: boolean; isLoadingOlderPrompts: boolean; onLoadEarlierPrompts: () => void; }; const ChatViewport = React.memo(({ currentSessionId, currentSessionKey, isDesktopExpandedInput, isMobile, directory, scrollRef, messageListRef, registerList, anchorMessageId, onAnchorReady, onAnchorSizeChanged, onIsAtEndChange, onTimelineDataChange, pendingRevealWork, renderedMessages, isLoadingOlder, sessionIsWorking, streamingMessageId, activeStreamingPhase, retryOverlay, scrollToBottom, endPinningReleased, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, showLoadOlderButton, onLoadOlder, turnIds, activeTurnId, onSelectTurn, showPromptNavigator, canLoadEarlierPrompts, isLoadingOlderPrompts, onLoadEarlierPrompts, }: ChatViewportProps) => { const { t } = useI18n(); const promptPreviewsByTurnIdRef = React.useRef>(new Map()); // Cache normalized parts per source array so unchanged messages keep the // same reference and the memo below can bail out to the previous map. const normalizedPromptPartsCache = React.useRef(new WeakMap()); // Shell-mode prompts show their extracted command; cache by message id so // the parts array reference is stable while the command is unchanged. const shellPreviewCache = React.useRef(new Map()); const shellPreviewSessionRef = React.useRef(currentSessionId); if (shellPreviewSessionRef.current !== currentSessionId) { shellPreviewSessionRef.current = currentSessionId; shellPreviewCache.current.clear(); } const promptPreviewsByTurnId = React.useMemo(() => { const next = new Map(); for (let index = 0; index < renderedMessages.length; index += 1) { const message = renderedMessages[index]; if (message.info.role !== 'user') { continue; } if (isUserShellMarkerMessage(message)) { const command = findShellCommandForMessage(renderedMessages, index) ?? ''; const cached = shellPreviewCache.current.get(message.info.id); if (cached && cached.command === command) { next.set(message.info.id, cached.parts); } else { const parts = [{ type: 'text', text: command ? `$ ${command}` : '/shell' } as Part]; shellPreviewCache.current.set(message.info.id, { command, parts }); next.set(message.info.id, parts); } continue; } // Other fully synthetic user messages (loop continuations, // plan-mode injections) are not prompts the user typed — keep // them out of the navigator entirely. if (isFullySyntheticMessage(message.parts)) { continue; } let displayParts = normalizedPromptPartsCache.current.get(message.parts); if (!displayParts) { displayParts = normalizeUserDisplayParts(message.parts); normalizedPromptPartsCache.current.set(message.parts, displayParts); } if (displayParts.length === 0) { continue; } next.set(message.info.id, displayParts); } const prev = promptPreviewsByTurnIdRef.current; if (prev.size === next.size) { let unchanged = true; for (const [id, parts] of next) { if (prev.get(id) !== parts) { unchanged = false; break; } } if (unchanged) { return prev; } } promptPreviewsByTurnIdRef.current = next; return next; }, [renderedMessages]); // Only real (non-synthetic) prompts become rail entries; selection still // targets the same turn anchors as the timeline. const promptTurnIds = React.useMemo( () => turnIds.filter((id) => promptPreviewsByTurnId.has(id)), [promptPreviewsByTurnId, turnIds], ); // If the viewport sits in a filtered-out (synthetic) turn, treat the // nearest preceding real prompt as active so the rail doesn't jump. const railActiveTurnId = React.useMemo(() => { if (!activeTurnId || promptPreviewsByTurnId.has(activeTurnId)) { return activeTurnId; } const activeIndex = turnIds.indexOf(activeTurnId); for (let index = activeIndex - 1; index >= 0; index -= 1) { const turnId = turnIds[index]; if (promptPreviewsByTurnId.has(turnId)) { return turnId; } } return null; }, [activeTurnId, promptPreviewsByTurnId, turnIds]); const focusScrollContainer = React.useCallback((event: React.MouseEvent) => { if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { return; } if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') { return; } scrollRef.current?.focus({ preventScroll: true }); }, [scrollRef]); // Everything that used to sit beside the list inside the scroll container // now renders as the list's header/footer, so it keeps scrolling with the // rows exactly as before. const listHeader = React.useMemo(() => ( showLoadOlderButton ? (
) : null ), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]); const listFooter = React.useMemo(() => ( <> {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
{sessionQuestions.map((question) => ( ))} {sessionPermissions.map((permission) => ( ))}
)}