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

991 lines
38 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
2025-12-07 19:32:53 +02:00
import { ChatInput } from './ChatInput';
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 { PermissionCard } from './PermissionCard';
import { QuestionCard } from './QuestionCard';
import { StatusRowContainer } from './StatusRowContainer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
2025-12-07 19:32:53 +02:00
import { ScrollShadow } from '@/components/ui/ScrollShadow';
2026-05-08 14:20:16 +03:00
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
2026-05-28 01:20:21 +03:00
import { useChatSurfaceMode } from './useChatSurfaceMode';
2025-12-07 19:32:53 +02:00
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
2025-12-07 19:32:53 +02:00
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { Icon } from "@/components/icon/Icon";
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
2026-05-27 23:36:26 +03:00
import { cn, formatDirectoryName } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import {
collectVisibleSessionIdsForBlockingRequests,
flattenBlockingRequests,
} from './lib/blockingRequests';
// New sync system imports
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useStreamingStore } from '@/sync/streaming';
import {
2026-04-05 15:58:06 +03:00
useSessionMessageCount,
useSessionMessageRecords,
useSessions,
useDirectorySync,
useSyncDirectory,
useSessionStatus,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n';
2026-05-28 01:13:03 +03:00
import { isVSCodeRuntime } from '@/lib/desktop';
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 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;
2026-04-26 18:17:13 +03:00
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[] };
2026-04-26 18:17:13 +03:00
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;
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;
2026-04-05 16:56:05 +03:00
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;
2026-05-30 02:03:41 +03:00
handleHistoryScroll: () => void;
2026-05-08 14:20:16 +03:00
scrollToBottom: () => void;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
};
const ChatViewport = React.memo(({
currentSessionId,
isDesktopExpandedInput,
isMobile,
stickyUserHeader,
scrollRef,
messageListRef,
turnStart,
pendingRevealWork,
renderedMessages,
hasMoreAboveTurns,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
2026-04-05 16:56:05 +03:00
activeStreamingPhase,
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleLoadOlder,
2026-05-30 02:03:41 +03:00
handleHistoryScroll,
scrollToBottom,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
}: ChatViewportProps) => {
2026-04-26 18:17:13 +03:00
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
return;
}
if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') {
return;
}
scrollRef.current?.focus({ preventScroll: true });
}, [scrollRef]);
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}
2026-04-26 18:17:13 +03:00
tabIndex={0}
onClick={focusScrollContainer}
2026-05-30 02:03:41 +03:00
onScroll={handleHistoryScroll}
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}
2026-04-05 16:56:05 +03:00
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
2026-04-05 16:56:05 +03:00
&& prev.activeStreamingPhase === next.activeStreamingPhase
&& prev.retryOverlay === next.retryOverlay
&& prev.handleMessageContentChange === next.handleMessageContentChange
&& prev.getAnimationHandlers === next.getAnimationHandlers
&& prev.handleLoadOlder === next.handleLoadOlder
2026-05-30 02:03:41 +03:00
&& prev.handleHistoryScroll === next.handleHistoryScroll
&& 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%]'],
},
];
const ReadOnlyPromptBanner: React.FC = () => {
const { t } = useI18n();
return (
<div className="p-3">
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground">
{t('chat.container.readOnlySubagentPromptBanner')}
</div>
</div>
);
};
2026-05-27 23:36:26 +03:00
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
const label = project.label?.trim();
return label || formatDirectoryName(project.path);
};
2026-05-28 01:25:55 +03:00
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)}
<span className="font-medium">{projectLabel}</span>
{title.slice(projectIndex + projectLabel.length)}
</>
);
};
type ChatContainerProps = {
autoOpenDraft?: boolean;
readOnly?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
const { t } = useI18n();
// 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);
2026-05-27 23:36:26 +03:00
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
// Sync actions
const sync = useSync();
const syncDirectory = useSyncDirectory();
const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
[sync],
);
const loadMoreMessages = React.useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
[sync],
);
2025-12-07 19:32:53 +02:00
// UI store
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
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],
),
);
2026-04-05 16:56:05 +03:00
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => {
if (!streamingMessageId) return null;
return s.messageStreamStates.get(streamingMessageId)?.phase ?? null;
},
[streamingMessageId],
),
);
2026-04-05 15:58:06 +03:00
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
const hasRenderableSessionSnapshot = useDirectorySync(
React.useCallback(
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
);
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore(
React.useCallback(
(notify) => currentSessionId
? subscribeSessionPrefetch(syncDirectory, currentSessionId, notify)
: () => undefined,
[currentSessionId, syncDirectory],
),
React.useCallback(
() => currentSessionId ? getSessionPrefetch(syncDirectory, currentSessionId) : undefined,
[currentSessionId, syncDirectory],
),
React.useCallback(() => undefined, []),
);
// Sessions from sync system
const sessions = useSessions();
// Plan detection - watches messages for plan creation and signals store
usePlanDetection(currentSessionId ?? '', sessionMessages);
// 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 || 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<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;
const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true;
return {
limit: sessionMessages.length,
complete: !(sync.hasMore(currentSessionId) || prefetchHasMore),
loading: sync.isLoading(currentSessionId),
};
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
2025-12-07 19:32:53 +02:00
const { isMobile } = useDeviceInfo();
2026-05-28 01:13:03 +03:00
const isVSCode = isVSCodeRuntime();
2026-05-28 01:20:21 +03:00
const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open);
const isDesktopExpandedInput = isExpandedInput && !isMobile;
2026-05-28 01:20:21 +03:00
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
const messageListRef = React.useRef<MessageListHandle | null>(null);
2026-05-27 23:36:26 +03:00
const draftProjectLabel = React.useMemo(() => {
const selectedProject = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
const project = selectedProject ?? activeProject ?? projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
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)
?? 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={t('chat.container.returnToParent.aria')}
title={parentSession.title?.trim()
? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
: t('chat.container.returnToParent.title')}
>
<Icon name="arrow-left" className="h-4 w-4" />
{t('chat.container.returnToParent.label')}
</Button>
) : null;
2026-05-15 22:24:27 +03:00
const promptReadOnly = readOnly || Boolean(parentSession);
React.useEffect(() => {
if (autoOpenDraft && !currentSessionId && !draftOpen) {
openNewSessionDraft();
}
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
activeTurnChangeRef.current(turnId);
}, []);
2025-12-07 19:32:53 +02:00
const {
scrollRef,
2026-05-08 14:20:16 +03:00
notifyContentChange: handleMessageContentChange,
2026-01-20 03:23:39 +02:00
getAnimationHandlers,
2026-05-08 14:20:16 +03:00
goToBottom,
releaseAutoFollow,
restoreSnapshot,
2026-01-20 03:23:39 +02:00
isPinned,
2026-05-08 14:20:16 +03:00
isFollowingProgrammatically,
showScrollButton,
} = useChatAutoFollow({
2025-12-07 19:32:53 +02:00
currentSessionId,
2026-04-05 15:58:06 +03:00
sessionMessageCount,
sessionIsWorking,
2025-12-07 19:32:53 +02:00
isMobile,
onActiveTurnChange: handleActiveTurnChange,
});
const viewportMessages = sessionMessages;
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
messages: viewportMessages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
2026-05-08 14:20:16 +03:00
goToBottom,
releaseAutoFollow,
isPinned,
2026-05-08 14:20:16 +03:00
showScrollButton,
});
2026-05-08 14:20:16 +03:00
const { loadEarlier } = timelineController;
2026-05-08 14:20:16 +03:00
const resumeToLatestInstant = React.useCallback(() => {
goToBottom('instant');
}, [goToBottom]);
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(() => {
2026-05-30 02:03:41 +03:00
void loadEarlier({ userInitiated: true });
}, [loadEarlier]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
activeTurnId: timelineController.activeTurnId,
scrollToTurn: timelineController.scrollToTurn,
scrollToMessage: timelineController.scrollToMessage,
resumeToBottom: timelineController.resumeToBottomInstant,
2025-12-07 19:32:53 +02:00
});
2026-05-08 14:20:16 +03:00
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]);
2026-04-26 18:17:13 +03:00
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 lastScrolledSessionRef = React.useRef<string | null>(null);
2025-12-07 19:32:53 +02:00
const isSessionHydrating =
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
React.useEffect(() => {
2026-05-08 14:20:16 +03:00
if (!currentSessionId) return;
if (lastScrolledSessionRef.current === currentSessionId) return;
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
2026-05-08 14:20:16 +03:00
lastScrolledSessionRef.current = currentSessionId;
if (hasHashTarget) {
2026-05-08 14:20:16 +03:00
// Hash navigation handler will scroll to target; we just release auto-follow.
releaseAutoFollow();
return;
}
2026-05-08 14:20:16 +03:00
const run = () => {
void restoreSnapshot();
};
if (typeof window === 'undefined') {
2026-05-08 14:20:16 +03:00
run();
} else {
window.requestAnimationFrame(run);
}
2026-05-08 14:20:16 +03:00
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
2025-12-07 19:32:53 +02:00
React.useEffect(() => {
if (!currentSessionId) return;
if (hasRenderableSessionSnapshot) return;
2026-05-08 14:20:16 +03:00
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
2025-12-07 19:32:53 +02:00
if (!currentSessionId && !draftOpen) {
return (
<div className="flex flex-col h-full bg-background">
<ChatEmptyState />
</div>
);
}
if (!currentSessionId && draftOpen) {
return (
2026-05-27 17:35:58 +03:00
<div className="relative flex h-full flex-col bg-background transform-gpu">
2026-05-28 01:13:03 +03:00
{useCompactDraftLayout && !isDesktopExpandedInput ? (
2026-05-27 23:36:26 +03:00
<div className="flex min-h-0 flex-1 items-center justify-center px-6 text-center">
2026-05-28 01:25:55 +03:00
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
draftProjectLabel
? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel })
: t('chat.emptyState.draftTitle'),
draftProjectLabel,
)}
2026-05-27 23:36:26 +03:00
</h1>
</div>
) : null}
2026-05-27 17:35:58 +03:00
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
2026-05-27 17:35:58 +03:00
? 'flex-1 bg-background'
2026-05-28 01:13:03 +03:00
: useCompactDraftLayout
2026-05-27 23:36:26 +03:00
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
2026-05-15 22:24:27 +03:00
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
</div>
);
}
if (!currentSessionId) {
return null;
}
2025-12-07 19:32:53 +02:00
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
return (
<div className="relative flex flex-col h-full bg-background">
{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" style={CHAT_SCROLL_STYLE}>
<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>
2025-12-07 19:32:53 +02:00
</div>
</div>
))}
</div>
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'
)}
>
2026-05-15 22:24:27 +03:00
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
</div>
);
2025-12-07 19:32:53 +02:00
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
<div className="relative flex flex-col h-full bg-background transform-gpu">
{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}
2025-12-07 19:32:53 +02:00
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
2026-05-15 22:24:27 +03:00
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div>
2025-12-07 19:32:53 +02:00
</div>
);
}
return (
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
key={currentSessionId}
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}
2026-04-05 16:56:05 +03:00
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
handleMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
handleLoadOlder={handleLoadOlder}
2026-05-30 02:03:41 +03:00
handleHistoryScroll={timelineController.handleHistoryScroll}
2026-05-08 14:20:16 +03:00
scrollToBottom={resumeToLatestInstant}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
2026-05-08 14:20:16 +03:00
isProgrammaticFollowActive={isFollowingProgrammatically}
/>
2025-12-07 19:32:53 +02:00
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
2025-12-07 19:32:53 +02:00
)}
2026-05-15 22:24:27 +03:00
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
2025-12-07 19:32:53 +02:00
</div>
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={timelineController.scrollToMessage}
onScrollByTurnOffset={navigation.scrollByTurnOffset}
onResumeToLatest={resumeToLatestInstant}
/>
2025-12-07 19:32:53 +02:00
</div>
);
};