Files
openchamber/packages/ui/src/components/chat/ChatContainer.tsx
T

854 lines
34 KiB
TypeScript

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<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
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 (
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0">
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={CHAT_SCROLL_STYLE}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
<MessageList
ref={messageListRef}
sessionKey={currentSessionId}
turnStart={turnStart}
disableStaging={pendingRevealWork}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAboveTurns}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
/>
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
<div>
{sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
<div className="mb-3">
<StatusRowContainer />
</div>
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
</div>
</div>
);
}, (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<string, PermissionRequest[]>();
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<string, QuestionRequest[]>();
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<number>(0);
const retryFallbackSessionRef = React.useRef<string | null>(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<MessageListHandle | null>(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 ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
aria-label="Return to parent session"
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
</Button>
) : 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<SessionMessageRecord[]>(EMPTY_MESSAGES);
const viewportSessionIdRef = React.useRef<string | null>(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<string>;
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<string | null>(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 (
<div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<ChatEmptyState />
</div>
);
}
if (!currentSessionId && draftOpen) {
return (
<div
className="relative flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
if (!currentSessionId) {
return null;
}
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-background pt-6">
<div className="space-y-4">
{HYDRATING_SKELETON_ITEMS.map((item) => (
<div key={item.id} className="group w-full">
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
{!isDesktopExpandedInput ? (
<div className="absolute inset-0 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
return (
<div
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
scrollRef={scrollRef}
messageListRef={messageListRef}
turnStart={timelineController.turnStart}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages}
hasMoreAboveTurns={timelineController.historySignals.hasMoreAboveTurns}
isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking}
streamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isProgrammaticFollowActive}
/>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
};