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

320 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
2025-12-07 19:32:53 +02:00
import { Skeleton } from '@/components/ui/skeleton';
2025-12-21 22:59:06 +02:00
import ChatEmptyState from './ChatEmptyState';
2025-12-07 19:32:53 +02:00
import MessageList from './MessageList';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
2025-12-07 19:32:53 +02:00
export const ChatContainer: React.FC = () => {
const {
currentSessionId,
messages,
permissions,
2026-01-09 21:38:22 +02:00
questions,
2025-12-07 19:32:53 +02:00
streamingMessageIds,
isLoading,
loadMessages,
loadMoreMessages,
updateViewportAnchor,
sessionMemoryState,
openNewSessionDraft,
2025-12-07 19:32:53 +02:00
isSyncing,
messageStreamStates,
trimToViewportWindow,
sessionActivityPhase,
newSessionDraft,
2025-12-07 19:32:53 +02:00
} = useSessionStore();
const {
isTimelineDialogOpen,
setTimelineDialogOpen,
} = useUIStore();
2025-12-07 19:32:53 +02:00
const streamingMessageId = React.useMemo(() => {
if (!currentSessionId) return null;
return streamingMessageIds.get(currentSessionId) ?? null;
}, [currentSessionId, streamingMessageIds]);
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
if (!currentSessionId && !draftOpen) {
openNewSessionDraft();
}
}, [currentSessionId, draftOpen, openNewSessionDraft]);
2025-12-07 19:32:53 +02:00
const sessionMessages = React.useMemo(() => {
return currentSessionId ? messages.get(currentSessionId) || [] : [];
}, [currentSessionId, messages]);
const sessionPermissions = React.useMemo(() => {
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
}, [currentSessionId, permissions]);
2026-01-09 21:38:22 +02:00
const sessionQuestions = React.useMemo(() => {
return currentSessionId ? questions.get(currentSessionId) || [] : [];
}, [currentSessionId, questions]);
const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions];
}, [sessionPermissions, sessionQuestions]);
2025-12-07 19:32:53 +02:00
const {
scrollRef,
handleMessageContentChange,
2026-01-20 03:23:39 +02:00
getAnimationHandlers,
showScrollButton,
scrollToBottom,
scrollToPosition,
isPinned,
2025-12-07 19:32:53 +02:00
} = useChatScrollManager({
currentSessionId,
sessionMessages,
streamingMessageId,
sessionMemoryState,
updateViewportAnchor,
isSyncing,
isMobile,
messageStreamStates,
2026-01-09 21:38:22 +02:00
sessionPermissions: sessionBlockingCards,
2025-12-07 19:32:53 +02:00
trimToViewportWindow,
});
const memoryState = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessionMemoryState.get(currentSessionId) ?? null;
}, [currentSessionId, sessionMemoryState]);
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove);
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
React.useEffect(() => {
setIsLoadingOlder(false);
}, [currentSessionId]);
const handleLoadOlder = React.useCallback(async () => {
if (!currentSessionId || isLoadingOlder) {
return;
}
const container = scrollRef.current;
const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null;
setIsLoadingOlder(true);
try {
await loadMoreMessages(currentSessionId, 'up');
if (container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight;
2026-01-20 03:23:39 +02:00
scrollToPosition(prevTop + heightDiff, { instant: true });
2025-12-07 19:32:53 +02:00
}
} finally {
setIsLoadingOlder(false);
}
2026-01-20 03:23:39 +02:00
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
2025-12-07 19:32:53 +02:00
// Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => {
const container = scrollRef.current;
if (!container) return;
// Find the message element by looking for data-message-id attribute
const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement;
if (messageElement) {
// Scroll to the message with some padding (50px from top)
const containerRect = container.getBoundingClientRect();
const messageRect = messageElement.getBoundingClientRect();
const offset = 50;
const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset;
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}, [scrollRef]);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const hasSessionMessages = messages.has(currentSessionId);
const existingMessages = hasSessionMessages ? messages.get(currentSessionId) ?? [] : [];
if (existingMessages.length > 0) {
return;
}
const load = async () => {
try {
await loadMessages(currentSessionId);
} finally {
2026-01-19 02:48:56 +02:00
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
2026-01-20 03:23:39 +02:00
// When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned;
2026-01-19 02:48:56 +02:00
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
2025-12-07 19:32:53 +02:00
scrollToBottom();
2026-01-19 02:48:56 +02:00
} else {
window.requestAnimationFrame(() => {
scrollToBottom();
});
}
2025-12-07 19:32:53 +02:00
}
}
};
void load();
2026-01-20 03:23:39 +02:00
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
2025-12-07 19:32:53 +02:00
if (!currentSessionId && !draftOpen) {
2025-12-07 19:32:53 +02:00
return (
<div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
2025-12-21 22:59:06 +02:00
<ChatEmptyState />
2025-12-07 19:32:53 +02:00
</div>
);
}
if (!currentSessionId && draftOpen) {
return (
<div
className="flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 flex items-center justify-center">
2025-12-21 22:59:06 +02:00
<ChatEmptyState />
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
if (!currentSessionId) {
return null;
}
2025-12-07 19:32:53 +02:00
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = messages.has(currentSessionId);
if (!hasMessagesEntry) {
return (
<div
className="flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
2025-12-07 19:32:53 +02:00
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-message-column space-y-4">
2025-12-07 19:32:53 +02:00
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 p-4">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
</div>
</div>
))}
</div>
</div>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
);
}
}
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
2025-12-07 19:32:53 +02:00
<div className="flex-1 flex items-center justify-center">
2025-12-21 22:59:06 +02:00
<ChatEmptyState />
2025-12-07 19:32:53 +02:00
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
return (
<div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
2025-12-07 19:32:53 +02:00
<div className="relative flex-1 min-h-0">
<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={{
contain: 'strict',
['--scroll-shadow-size' as string]: '48px',
}}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
<MessageList
messages={sessionMessages}
permissions={sessionPermissions}
2026-01-09 21:38:22 +02:00
questions={sessionQuestions}
2025-12-07 19:32:53 +02:00
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAbove}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
/>
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} />
</div>
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
{showScrollButton && sessionMessages.length > 0 && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
<Button
variant="outline"
size="sm"
2025-12-13 16:34:17 +02:00
onClick={() => scrollToBottom({ force: true })}
2025-12-07 19:32:53 +02:00
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
aria-label="Scroll to bottom"
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
</div>
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={scrollToMessage}
/>
2025-12-07 19:32:53 +02:00
</div>
);
};