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 { ScrollShadow } from '@/components/ui/ScrollShadow'; import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; 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'; 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 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; stickyUserHeader: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; 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; handleMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; handleHistoryScroll: () => void; scrollToBottom: () => void; 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, stickyUserHeader, directory, scrollRef, messageListRef, pendingRevealWork, renderedMessages, isLoadingOlder, sessionIsWorking, streamingMessageId, activeStreamingPhase, retryOverlay, handleMessageContentChange, getAnimationHandlers, handleHistoryScroll, scrollToBottom, 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]); return (
{showLoadOlderButton && (
)} {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
{sessionQuestions.map((question) => ( ))} {sessionPermissions.map((permission) => ( ))}
)}
{showPromptNavigator && promptTurnIds.length >= 2 ? ( ) : null}
); }, (prev, next) => { return prev.currentSessionId === next.currentSessionId && prev.currentSessionKey === next.currentSessionKey && prev.isDesktopExpandedInput === next.isDesktopExpandedInput && prev.isMobile === next.isMobile && prev.stickyUserHeader === next.stickyUserHeader && prev.directory === next.directory && prev.scrollRef === next.scrollRef && prev.messageListRef === next.messageListRef && prev.pendingRevealWork === next.pendingRevealWork && prev.renderedMessages === next.renderedMessages && 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.handleHistoryScroll === next.handleHistoryScroll && prev.scrollToBottom === next.scrollToBottom && prev.sessionQuestions === next.sessionQuestions && prev.sessionPermissions === next.sessionPermissions && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive && prev.showLoadOlderButton === next.showLoadOlderButton && prev.onLoadOlder === next.onLoadOlder && prev.turnIds === next.turnIds && prev.activeTurnId === next.activeTurnId && prev.onSelectTurn === next.onSelectTurn && prev.showPromptNavigator === next.showPromptNavigator && prev.canLoadEarlierPrompts === next.canLoadEarlierPrompts && prev.isLoadingOlderPrompts === next.isLoadingOlderPrompts && prev.onLoadEarlierPrompts === next.onLoadEarlierPrompts; }); 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%]'], }, ]; const ReadOnlyPromptBanner: React.FC = () => { const { t } = useI18n(); return (
{t('chat.container.readOnlySubagentPromptBanner')}
); }; const getProjectDisplayLabel = (project: { label?: string; path: string }): string => { const label = project.label?.trim(); return label || formatDirectoryName(project.path); }; const renderDraftTitle = (title: string, projectLabel: string | null): React.ReactNode => { if (!projectLabel) return title; const projectIndex = title.indexOf(projectLabel); if (projectIndex === -1) return title; return ( <> {title.slice(0, projectIndex)} {projectLabel} {title.slice(projectIndex + projectLabel.length)} ); }; const DraftWelcome: React.FC = () => { const { t } = useI18n(); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); const projectLabel = useProjectsStore(React.useCallback((state) => { const projectId = selectedProjectId ?? state.activeProjectId; const project = (projectId ? state.projects.find((candidate) => candidate.id === projectId) : null) ?? state.projects[0] ?? null; return project ? getProjectDisplayLabel(project) : null; }, [selectedProjectId])); return (

{renderDraftTitle( projectLabel ? t('chat.emptyState.draftTitleWithProject', { project: projectLabel }) : t('chat.emptyState.draftTitle'), projectLabel, )}

useInputStore.getState().requestPresetSubmit(starter.submitText, starter.ref.type)} className="oc-draft-starters mt-8 max-w-md" />
); }; type ChatContainerProps = { active?: boolean; /** * When set, controls message-history reads and session-message loads * independently of `active`. Defaults to `active`. Embedded session-chat * panels pass `true` so a delayed/lost visibility handshake cannot hide * an already-materialized transcript (leaving only the working-status * row — issue #2903). */ messagesEnabled?: boolean; autoOpenDraft?: boolean; readOnly?: boolean; initialAllowPromptingSubagentSessions?: boolean; }; export const ChatContainer: React.FC = ({ active = true, messagesEnabled: messagesEnabledProp, autoOpenDraft = true, readOnly = false, initialAllowPromptingSubagentSessions, }) => { const messagesEnabled = messagesEnabledProp ?? active; const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); // Sync actions const sync = useSync(); const syncDirectory = useSyncDirectory(); const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory; const currentSessionKey = currentSessionId ? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId]) : null; const ensureSessionRenderable = React.useCallback( (sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory), [effectiveSessionDirectory, sync], ); const loadMoreMessages = React.useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory), [effectiveSessionDirectory, sync], ); // UI store const isExpandedInput = useUIStore((state) => state.isExpandedInput); const stickyUserHeader = useUIStore((state) => state.stickyUserHeader); const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled); const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions); const [embeddedAllowPrompting, setEmbeddedAllowPrompting] = React.useState(initialAllowPromptingSubagentSessions); const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen); const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen); // 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 ?? '', effectiveSessionDirectory); const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory); // Messages from sync system. Keep this gated by `messagesEnabled`, not // `active`, so embedded panels can show history while the composer stays // inactive until the parent confirms visibility. const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, { enabled: messagesEnabled, suspendPartUpdates: Boolean(streamingMessageId), suspendPartUpdatesForMessageId: streamingMessageId, }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; const sessionMessageLoadState = useSessionMessageLoadState( currentSessionId ?? '', effectiveSessionDirectory, ); const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker); React.useEffect(() => { if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return; return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length); }, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]); // Plan detection - watches messages for plan creation and signals store usePlanDetection(currentSessionId ?? '', sessionMessages); // Session status from sync system const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS; // Scoped blocking requests — only subscribe to permissions/questions for // the current session + descendant subagent sessions, not all sessions in // the directory. const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory); const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory); const hasUnreconciledQuestionTool = React.useMemo( () => !sessionQuestions.some((question) => question.sessionID === currentSessionId) && hasActiveQuestionToolInCurrentTurn(sessionMessages), [currentSessionId, sessionMessages, sessionQuestions], ); React.useEffect(() => { if (!active || !currentSessionId || !effectiveSessionDirectory || !hasUnreconciledQuestionTool) return; let cancelled = false; void recoverPendingQuestionWithRetry( () => sync.recoverPendingQuestions(currentSessionId, effectiveSessionDirectory), { isCancelled: () => cancelled }, ); return () => { cancelled = true; }; }, [active, currentSessionId, effectiveSessionDirectory, hasUnreconciledQuestionTool, sync]); const sessionIsWorking = React.useMemo(() => { if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.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, sessionQuestions.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: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor, loading: sessionMessageLoadState.status === 'loading', }; }, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]); const { isMobile } = useDeviceInfo(); const isVSCode = isVSCodeRuntime(); const chatSurfaceMode = useChatSurfaceMode(); const draftOpen = Boolean(newSessionDraft?.open); // A draft can target another project or a pending worktree before it has a // session. Keep the panel on that same directory so its project, MCP, and // usage readouts describe where the draft will run rather than the project // the user came from. const workStatusDirectory = draftOpen ? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory : effectiveSessionDirectory; const initError = useGlobalSyncStore((s) => s.error); // Despite the historical name, this now covers mobile too: the mobile // composer enters the same fullscreen-input mode via its drag handle. const isDesktopExpandedInput = isExpandedInput; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; // Work-status panel: a borderless column to the right of the transcript. // It yields to the context panel and to a narrow chat; `rowRef` goes on the // row that holds both columns, so its width never depends on the panel's // own visibility. const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ directory: workStatusDirectory, isMobile, isVSCode, }); // Surfaces that never host the panel skip it entirely; the rest keep it // mounted so its visibility can animate rather than snap. const workStatusPanelMountable = !isMobile && !isVSCode && chatSurfaceMode !== 'mini-chat' && !isDesktopExpandedInput; const showWorkStatusPanel = workStatusPanelMountable && workStatusVisible; // Offered over the chat when there is no room beside it. The panel is still // switched on; only the layout refuses it. const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled); const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen); const setWorkStatusPanelFits = useUIStore((state) => state.setWorkStatusPanelFits); // Mounted whenever it could be shown, not only while it is: an element // that appears and disappears with the condition has nothing to animate. const workStatusOverlayMountable = workStatusPanelMountable && workStatusPanelEnabled && !workStatusFits; const showWorkStatusOverlay = workStatusOverlayMountable && workStatusOverlayOpen; React.useEffect(() => { setWorkStatusPanelFits(workStatusPanelMountable && workStatusFits); return () => setWorkStatusPanelFits(false); }, [setWorkStatusPanelFits, workStatusFits, workStatusPanelMountable]); // Published so the header can drop the readouts the panel already carries. // Cleared on unmount: a chat that goes away is not showing anything. const setWorkStatusPanelVisible = useUIStore((state) => state.setWorkStatusPanelVisible); React.useEffect(() => { setWorkStatusPanelVisible(showWorkStatusPanel); return () => setWorkStatusPanelVisible(false); }, [setWorkStatusPanelVisible, showWorkStatusPanel]); const messageListRef = React.useRef(null); const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); // In the embedded session-chat iframe, hide "Return to parent" when // viewing the panel's anchor session (the one recorded in the URL). Going // up from the anchor would show the primary session that's already in the // main chat. Drilling into a deeper subtask (currentSessionId ≠ anchor) // re-enables the button to navigate back to the embedded session. const embeddedPanelAnchorSessionId = getEmbeddedSessionChatOriginSessionId(); const hideReturnToParent = embeddedPanelAnchorSessionId !== null && currentSessionId === embeddedPanelAnchorSessionId; 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 && !hideReturnToParent ? ( ) : null; const promptReadOnly = resolveChatPromptReadOnly( currentSession, embeddedAllowPrompting ?? allowPromptingSubagentSessions, readOnly, ); React.useEffect(() => { // VS Code/Cursor/Positron webviews delete window.parent (and window.top). // The old `window.parent === window` check does not catch that, so // `window.parent.postMessage(...)` threw on chat open: // TypeError: Cannot read properties of undefined (reading 'postMessage') if (typeof window === 'undefined' || !window.parent || window.parent === window) { return; } const parentWindow = window.parent; const applySetting = (value: boolean) => { setEmbeddedAllowPrompting(value); useUIStore.getState().setAllowPromptingSubagentSessions(value); }; const scopedWindow = window as typeof window & { __openchamberApplyChatSettingsSync?: (payload: { allowPromptingSubagentSessions: boolean }) => void; }; const applySync = (payload: { allowPromptingSubagentSessions: boolean }) => { applySetting(payload.allowPromptingSubagentSessions); }; const handleMessage = (event: MessageEvent) => { if (event.source !== parentWindow || event.origin !== window.location.origin) return; const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } }; if (data?.type !== 'openchamber:chat-settings-sync' || typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return; applySetting(data.payload.allowPromptingSubagentSessions); }; scopedWindow.__openchamberApplyChatSettingsSync = applySync; window.addEventListener('message', handleMessage); parentWindow.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin); return () => { window.removeEventListener('message', handleMessage); if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) { delete scopedWindow.__openchamberApplyChatSettingsSync; } }; }, []); React.useEffect(() => { if (autoOpenDraft && !currentSessionId && !draftOpen) { // Programmatic fallback, not user navigation — must not clear the // persisted last-session pointer the cold-launch restore reads. openNewSessionDraft({ automatic: true }); } }, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]); const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {}); const handleActiveTurnChange = React.useCallback((turnId: string | null) => { activeTurnChangeRef.current(turnId); }, []); const { scrollRef, notifyContentChange: handleMessageContentChange, getAnimationHandlers, goToBottom, scrollToBottomOnSend, releaseAutoFollow, restoreSnapshot, isPinned, isFollowingProgrammatically, showScrollButton, } = useChatAutoFollow({ currentSessionId, currentSessionKey, sessionMessageCount, sessionIsWorking, isMobile, onActiveTurnChange: handleActiveTurnChange, }); const viewportMessages = sessionMessages; const timelineController = useChatTimelineController({ sessionId: currentSessionId, sessionKey: currentSessionKey, messages: viewportMessages, historyMeta, scrollRef, messageListRef, loadMoreMessages, goToBottom, releaseAutoFollow, isPinned, showScrollButton, }); const resumeToLatestInstant = React.useCallback(() => { goToBottom('instant'); }, [goToBottom]); // Mobile loads older history via an explicit top button instead of a // scroll-position trigger (see handleHistoryScroll in the controller). const showLoadOlderButton = isMobileSurfaceRuntime() && timelineController.historySignals.canLoadEarlier; const timelineLoadEarlier = timelineController.loadEarlier; const handleLoadOlderClick = React.useCallback(() => { void timelineLoadEarlier({ userInitiated: true }); }, [timelineLoadEarlier]); React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); React.useEffect(() => { if (sessionPermissions.length === 0 && sessionQuestions.length === 0) { return; } handleMessageContentChange('permission'); }, [handleMessageContentChange, sessionPermissions, sessionQuestions]); const navigation = useChatTurnNavigation({ sessionId: currentSessionId, turnIds: timelineController.turnIds, activeTurnId: timelineController.activeTurnId, scrollToTurn: timelineController.scrollToTurn, scrollToMessage: timelineController.scrollToMessage, resumeToBottom: timelineController.resumeToBottomInstant, }); const handlePromptNavigatorSelect = React.useCallback((turnId: string) => { void navigation.scrollToTurnId(turnId, { behavior: 'smooth' }); }, [navigation]); const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier; const showPromptNavigator = !isMobile && !isVSCode && !isDesktopExpandedInput && promptNavigatorEnabled && timelineController.turnIds.length >= 2; React.useEffect(() => { if (!showPromptNavigator) { useUIStore.getState().setPromptNavigatorPanelOpen(false); } }, [showPromptNavigator]); React.useEffect(() => { if (typeof window === 'undefined' || !currentSessionId) return; const handleForceScrollBottom = (event: Event) => { const customEvent = event as CustomEvent<{ sessionId?: string }>; if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return; goToBottom('instant'); }; window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener); return () => { window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener); }; }, [currentSessionId, goToBottom]); React.useEffect(() => { if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) { return; } const handleChatTurnKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) { return; } if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { return; } if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } const { activeMainTab } = useUIStore.getState(); if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) { return; } const scrollContainer = scrollRef.current; if (shouldIgnoreChatNavigationForFocus(document.activeElement, scrollContainer)) { return; } if (shouldIgnoreChatNavigationTarget(event.target)) { return; } event.preventDefault(); const offset = event.key === 'ArrowUp' ? -1 : 1; void navigation.scrollByTurnOffset(offset, { resumePastEnd: false }); }; window.addEventListener('keydown', handleChatTurnKeyDown); return () => { window.removeEventListener('keydown', handleChatTurnKeyDown); }; }, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]); 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 lastScrolledSessionKeyRef = React.useRef(null); const isSessionHydrating = Boolean(currentSessionId) && !hasRenderableSessionSnapshot; const retrySessionLoad = React.useCallback(() => { if (!messagesEnabled || !currentSessionId) return; void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); }, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]); React.useEffect(() => { if (!active || !currentSessionId) return; if (lastScrolledSessionKeyRef.current === currentSessionKey) return; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; lastScrolledSessionKeyRef.current = currentSessionKey; if (hasHashTarget) { // Hash navigation handler will scroll to target; we just release auto-follow. releaseAutoFollow(); return; } const run = () => { void restoreSnapshot(); }; if (typeof window === 'undefined') { run(); } else { window.requestAnimationFrame(run); } }, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]); React.useEffect(() => { if (!messagesEnabled || !currentSessionId) return; if (hasRenderableSessionSnapshot) return; void ensureSessionRenderable(currentSessionId); }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); if (!currentSessionId && !draftOpen) { // With auto-open, the draft welcome opens on the next tick (effect below), // so the empty state is only ever transient here — render a neutral // background instead of flashing the logo / "start a new chat" on refresh. // Keep the empty state when there's nothing to auto-open or an init error to show. if (autoOpenDraft && !initError) { return
; } return (
); } if (!currentSessionId && draftOpen) { return ( // No transform on this root: it would become the containing block for // the fullscreen composer's position:fixed visual-viewport pinning in // mobile browsers (see ChatInput's composerFormRef effect).
{useCompactDraftLayout && !isDesktopExpandedInput ? : null}
{promptReadOnly ? : }
{workStatusOverlayMountable ? ( ) : null}
{workStatusPanelMountable ? ( ) : null}
); } if (!currentSessionId) { return null; } if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { if (sessionMessageLoadState.status === 'error') { return (
{returnToParentButton}

{t('chat.container.sessionLoadError.title')}

{t('chat.container.sessionLoadError.description')}

{promptReadOnly ? : }
); } return (
{returnToParentButton}
{HYDRATING_SKELETON_ITEMS.map((item) => (
{item.toolRows.map((row) => { return (
); })}
))}
{promptReadOnly ? : }
); } if (sessionMessages.length === 0 && !sessionIsWorking) { return ( // No transform here either — same fixed-positioning constraint as the // draft branch above.
{returnToParentButton}
{!isDesktopExpandedInput ? (
) : null}
{promptReadOnly ? : }
); } return (
{returnToParentButton}
{!isDesktopExpandedInput && sessionMessages.length > 0 && ( )} {promptReadOnly ? : }
{/* Inside the chat column, not beside it: as a row sibling it took part in the flex layout and pushed the transcript, which is the one thing an overlay must not do. */} {workStatusOverlayMountable ? ( ) : null}
{/* Kept mounted while it could ever show, so it can animate its own collapse; `visible` drives that. Unmounting on the spot is what made the chat jump wide before easing narrow again. */} {workStatusPanelMountable ? ( ) : null}
); };