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

527 lines
20 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
import { RiArrowLeftLine } from '@remixicon/react';
import { useShallow } from 'zustand/react/shallow';
import type { Message, Part } from '@opencode-ai/sdk/v2';
2025-12-07 19:32:53 +02:00
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';
import MessageList, { type MessageListHandle } from './MessageList';
import ScrollToBottomButton from './components/ScrollToBottomButton';
2025-12-07 19:32:53 +02:00
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
2025-12-07 19:32:53 +02:00
import { useDeviceInfo } from '@/lib/device';
import { ButtonSmall } from '@/components/ui/button-small';
2025-12-07 19:32:53 +02:00
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { cn } from '@/lib/utils';
import {
collectVisibleSessionIdsForBlockingRequests,
flattenBlockingRequests,
} from './lib/blockingRequests';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
2025-12-07 19:32:53 +02:00
type HydratingToolSkeletonRow = {
id: string;
titleWidth: string;
detailWidth: string;
};
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%]'],
},
];
2025-12-07 19:32:53 +02:00
export const ChatContainer: React.FC = () => {
const {
currentSessionId,
loadMessages,
loadMoreMessages,
updateViewportAnchor,
openNewSessionDraft,
setCurrentSession,
newSessionDraft,
} = useSessionStore(
useShallow((state) => ({
currentSessionId: state.currentSessionId,
loadMessages: state.loadMessages,
loadMoreMessages: state.loadMoreMessages,
updateViewportAnchor: state.updateViewportAnchor,
openNewSessionDraft: state.openNewSessionDraft,
setCurrentSession: state.setCurrentSession,
newSessionDraft: state.newSessionDraft,
}))
);
const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
useShallow((state) => ({
isSyncing: state.isSyncing,
messageStreamStates: state.messageStreamStates,
sessionMemoryStateMap: state.sessionMemoryState,
}))
);
2025-12-07 19:32:53 +02:00
const {
isTimelineDialogOpen,
setTimelineDialogOpen,
isExpandedInput,
stickyUserHeader,
chatRenderMode,
} = useUIStore();
const sessionMessages = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
[currentSessionId]
)
);
const sessions = useSessionStore((state) => state.sessions);
const blockingRequestState = useSessionStore(
useShallow((state) => ({
sessions: state.sessions,
permissions: state.permissions,
questions: state.questions,
}))
);
const scopedSessionIds = React.useMemo(
() => collectVisibleSessionIdsForBlockingRequests(
blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
currentSessionId,
),
[blockingRequestState.sessions, currentSessionId]
);
const sessionPermissions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
}, [blockingRequestState.permissions, scopedSessionIds]);
const sessionQuestions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
}, [blockingRequestState.questions, scopedSessionIds]);
const historyMeta = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
const streamingMessageId = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
const sessionStatusForCurrent = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
[currentSessionId]
)
);
const hasSessionMessagesEntry = useSessionStore(
React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
);
2025-12-07 19:32:53 +02:00
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
const isDesktopExpandedInput = isExpandedInput && !isMobile;
const messageListRef = React.useRef<MessageListHandle | null>(null);
2025-12-07 19:32:53 +02:00
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) ?? null;
}, [currentSessionId, sessions]);
const handleReturnToParentSession = React.useCallback(() => {
if (!parentSession) {
return;
}
void setCurrentSession(parentSession.id);
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
<ButtonSmall
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
</ButtonSmall>
) : null;
React.useEffect(() => {
if (!currentSessionId && !draftOpen) {
openNewSessionDraft();
}
}, [currentSessionId, draftOpen, openNewSessionDraft]);
2026-01-09 21:38:22 +02:00
const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions];
}, [sessionPermissions, sessionQuestions]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
2025-12-07 19:32:53 +02:00
const {
scrollRef,
handleMessageContentChange,
2026-01-20 03:23:39 +02:00
getAnimationHandlers,
scrollToBottom,
releasePinnedScroll,
2026-01-20 03:23:39 +02:00
isPinned,
isOverflowing,
isProgrammaticFollowActive,
2025-12-07 19:32:53 +02:00
} = useChatScrollManager({
currentSessionId,
sessionMessages,
2025-12-07 19:32:53 +02:00
streamingMessageId,
sessionMemoryState: sessionMemoryStateMap,
2025-12-07 19:32:53 +02:00
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode,
2025-12-07 19:32:53 +02:00
messageStreamStates,
2026-01-09 21:38:22 +02:00
sessionPermissions: sessionBlockingCards,
onActiveTurnChange: (turnId) => {
activeTurnChangeRef.current(turnId);
},
});
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
messages: sessionMessages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
scrollToBottom,
isPinned,
isOverflowing,
});
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
activeTurnId: timelineController.activeTurnId,
scrollToTurn: timelineController.scrollToTurn,
scrollToMessage: timelineController.scrollToMessage,
resumeToBottom: timelineController.resumeToBottom,
2025-12-07 19:32:53 +02:00
});
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
return;
}
const updateChatScrollHeight = () => {
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
};
updateChatScrollHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateChatScrollHeight);
return () => {
window.removeEventListener('resize', updateChatScrollHeight);
};
}
const resizeObserver = new ResizeObserver(updateChatScrollHeight);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
const hasHistoryMetadata = React.useMemo(() => {
return Boolean(historyMeta);
}, [historyMeta]);
2025-12-07 19:32:53 +02:00
const isSessionHydrating =
Boolean(currentSessionId)
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const hasSessionMessages = hasSessionMessagesEntry;
if (hasSessionMessages && hasHistoryMetadata) {
2025-12-07 19:32:53 +02:00
return;
}
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 = (isActivePhase && isPinned) || hasHashTarget;
2026-01-19 02:48:56 +02:00
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
scrollToBottom({ instant: true });
2026-01-19 02:48:56 +02:00
} else {
window.requestAnimationFrame(() => {
scrollToBottom({ instant: true });
2026-01-19 02:48:56 +02:00
});
}
2025-12-07 19:32:53 +02:00
}
});
2025-12-07 19:32:53 +02:00
};
void load();
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
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="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 showDraftContext />
</div>
) : null}
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
)}
>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
if (!currentSessionId) {
return null;
}
2025-12-07 19:32:53 +02:00
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto 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>
2025-12-07 19:32:53 +02:00
</div>
</div>
</div>
))}
2025-12-07 19:32:53 +02:00
</div>
</div>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
);
2025-12-07 19:32:53 +02:00
}
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}
{!isDesktopExpandedInput ? (
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>
) : null}
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
)}
>
2025-12-07 19:32:53 +02:00
<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}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
)}
aria-hidden={isDesktopExpandedInput}
>
2025-12-07 19:32:53 +02:00
<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}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
data-scroll-shadow="true"
data-scrollbar="chat"
>
2025-12-07 19:32:53 +02:00
<div className="relative z-0 min-h-full">
<MessageList
ref={messageListRef}
sessionKey={currentSessionId}
turnStart={timelineController.turnStart}
disableStaging={timelineController.pendingRevealWork}
messages={timelineController.renderedMessages}
2025-12-07 19:32:53 +02:00
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={timelineController.historySignals.hasMoreAboveTurns}
isLoadingOlder={timelineController.isLoadingOlder}
onLoadOlder={() => {
void timelineController.loadEarlier();
}}
2025-12-07 19:32:53 +02:00
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
2025-12-07 19:32:53 +02:00
/>
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly />
2025-12-07 19:32:53 +02:00
</div>
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
2025-12-07 19:32:53 +02:00
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={(messageId) => {
releasePinnedScroll();
return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false });
}}
onScrollByTurnOffset={(offset) => {
releasePinnedScroll();
void navigation.scrollByTurnOffset(offset);
}}
onResumeToLatest={navigation.resumeToLatest}
/>
2025-12-07 19:32:53 +02:00
</div>
);
};