2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-04-03 12:38:03 +03:00
|
|
|
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
2026-06-18 08:43:16 +11:00
|
|
|
import type { PermissionRequest } from '@/types/permission';
|
|
|
|
|
import type { QuestionRequest } from '@/types/question';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
import { ChatInput } from './ChatInput';
|
2026-05-29 13:31:07 +03:00
|
|
|
import { DraftPresetChips } from './DraftPresetChips';
|
|
|
|
|
import { useInputStore } from '@/sync/input-store';
|
2026-01-02 18:33:35 -05:00
|
|
|
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';
|
2026-05-30 00:16:28 +03:00
|
|
|
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
2026-02-27 20:03:42 +02:00
|
|
|
import MessageList, { type MessageListHandle } from './MessageList';
|
2026-04-05 15:36:11 +03:00
|
|
|
import { PermissionCard } from './PermissionCard';
|
|
|
|
|
import { QuestionCard } from './QuestionCard';
|
|
|
|
|
import { StatusRowContainer } from './StatusRowContainer';
|
2026-07-05 23:19:10 +03:00
|
|
|
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
2026-03-12 23:45:45 +02:00
|
|
|
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';
|
2026-03-12 23:45:45 +02:00
|
|
|
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
2026-05-05 16:21:06 +08:00
|
|
|
import { TimelineDialog } from './TimelineDialog';
|
2026-03-12 23:45:45 +02:00
|
|
|
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';
|
2026-03-20 01:01:03 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
2025-12-07 19:32:53 +02:00
|
|
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-05-27 23:36:26 +03:00
|
|
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
|
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
2026-02-12 20:36:02 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// 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,
|
2026-03-31 18:47:00 +03:00
|
|
|
useSessionMessageRecords,
|
2026-05-21 15:45:15 +03:00
|
|
|
useSyncDirectory,
|
2026-06-18 08:43:16 +11:00
|
|
|
useDirectorySync,
|
2026-03-31 18:47:00 +03:00
|
|
|
useSessionStatus,
|
2026-06-18 08:43:16 +11:00
|
|
|
useScopedBlockingPermissions,
|
|
|
|
|
useScopedBlockingQuestions,
|
|
|
|
|
useParentSession,
|
2026-03-31 18:47:00 +03:00
|
|
|
} from '@/sync/sync-context';
|
|
|
|
|
import { useSync } from '@/sync/use-sync';
|
2026-05-21 15:45:15 +03:00
|
|
|
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
|
2026-05-07 18:57:44 +03:00
|
|
|
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
2026-04-06 20:44:13 +03:00
|
|
|
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-07-03 13:49:53 +03:00
|
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
2026-05-28 01:13:03 +03:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-02-12 20:36:02 +02:00
|
|
|
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
|
|
|
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
2026-05-01 13:48:07 +03:00
|
|
|
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
2026-04-05 15:36:11 +03:00
|
|
|
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
2026-04-27 16:14:09 +07:00
|
|
|
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(',');
|
2026-04-05 15:36:11 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
type HydratingToolSkeletonRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
titleWidth: string;
|
|
|
|
|
detailWidth: string;
|
2026-02-18 20:08:42 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
type ChatViewportProps = {
|
|
|
|
|
currentSessionId: string;
|
|
|
|
|
isDesktopExpandedInput: boolean;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
stickyUserHeader: boolean;
|
2026-06-18 08:43:16 +11:00
|
|
|
directory?: string;
|
2026-04-05 15:36:11 +03:00
|
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
|
|
|
messageListRef: React.RefObject<MessageListHandle | null>;
|
|
|
|
|
pendingRevealWork: boolean;
|
|
|
|
|
renderedMessages: SessionMessageRecord[];
|
|
|
|
|
isLoadingOlder: boolean;
|
|
|
|
|
sessionIsWorking: boolean;
|
|
|
|
|
streamingMessageId: string | null;
|
2026-04-05 16:56:05 +03:00
|
|
|
activeStreamingPhase: import('./message/types').StreamPhase | null;
|
2026-04-05 15:36:11 +03:00
|
|
|
retryOverlay: {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
message: string;
|
|
|
|
|
confirmedAt?: number;
|
|
|
|
|
fallbackTimestamp?: number;
|
|
|
|
|
} | null;
|
|
|
|
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
|
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
2026-05-30 02:03:41 +03:00
|
|
|
handleHistoryScroll: () => void;
|
2026-05-08 14:20:16 +03:00
|
|
|
scrollToBottom: () => void;
|
2026-04-05 15:36:11 +03:00
|
|
|
sessionQuestions: QuestionRequest[];
|
|
|
|
|
sessionPermissions: PermissionRequest[];
|
|
|
|
|
isProgrammaticFollowActive: boolean;
|
2026-07-03 13:49:53 +03:00
|
|
|
showLoadOlderButton: boolean;
|
|
|
|
|
onLoadOlder: () => void;
|
2026-04-05 15:36:11 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ChatViewport = React.memo(({
|
|
|
|
|
currentSessionId,
|
|
|
|
|
isDesktopExpandedInput,
|
|
|
|
|
isMobile,
|
|
|
|
|
stickyUserHeader,
|
2026-06-18 08:43:16 +11:00
|
|
|
directory,
|
2026-04-05 15:36:11 +03:00
|
|
|
scrollRef,
|
|
|
|
|
messageListRef,
|
|
|
|
|
pendingRevealWork,
|
|
|
|
|
renderedMessages,
|
|
|
|
|
isLoadingOlder,
|
|
|
|
|
sessionIsWorking,
|
|
|
|
|
streamingMessageId,
|
2026-04-05 16:56:05 +03:00
|
|
|
activeStreamingPhase,
|
2026-04-05 15:36:11 +03:00
|
|
|
retryOverlay,
|
|
|
|
|
handleMessageContentChange,
|
|
|
|
|
getAnimationHandlers,
|
2026-05-30 02:03:41 +03:00
|
|
|
handleHistoryScroll,
|
2026-04-05 15:36:11 +03:00
|
|
|
scrollToBottom,
|
|
|
|
|
sessionQuestions,
|
|
|
|
|
sessionPermissions,
|
|
|
|
|
isProgrammaticFollowActive,
|
2026-07-03 13:49:53 +03:00
|
|
|
showLoadOlderButton,
|
|
|
|
|
onLoadOlder,
|
2026-04-05 15:36:11 +03:00
|
|
|
}: ChatViewportProps) => {
|
2026-07-03 13:49:53 +03:00
|
|
|
const { t } = useI18n();
|
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]);
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
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}
|
2026-04-05 15:36:11 +03:00
|
|
|
data-scroll-shadow="true"
|
|
|
|
|
data-scrollbar="chat"
|
|
|
|
|
>
|
|
|
|
|
<div className="relative z-0 min-h-full">
|
2026-07-03 13:49:53 +03:00
|
|
|
{showLoadOlderButton && (
|
|
|
|
|
<div className="flex justify-center pt-3 pb-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onLoadOlder}
|
|
|
|
|
disabled={isLoadingOlder}
|
|
|
|
|
>
|
|
|
|
|
{isLoadingOlder && (
|
|
|
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
|
|
|
)}
|
|
|
|
|
{t('chat.history.loadOlder')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-05 15:36:11 +03:00
|
|
|
<MessageList
|
|
|
|
|
ref={messageListRef}
|
|
|
|
|
sessionKey={currentSessionId}
|
|
|
|
|
disableStaging={pendingRevealWork}
|
|
|
|
|
messages={renderedMessages}
|
|
|
|
|
sessionIsWorking={sessionIsWorking}
|
|
|
|
|
activeStreamingMessageId={streamingMessageId}
|
2026-04-05 16:56:05 +03:00
|
|
|
activeStreamingPhase={activeStreamingPhase}
|
2026-04-05 15:36:11 +03:00
|
|
|
retryOverlay={retryOverlay}
|
|
|
|
|
onMessageContentChange={handleMessageContentChange}
|
|
|
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
|
|
|
isLoadingOlder={isLoadingOlder}
|
|
|
|
|
scrollToBottom={scrollToBottom}
|
|
|
|
|
scrollRef={scrollRef}
|
2026-06-18 08:43:16 +11:00
|
|
|
directory={directory}
|
2026-04-05 15:36:11 +03:00
|
|
|
/>
|
|
|
|
|
{(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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-06 20:00:53 +03:00
|
|
|
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
2026-07-05 23:19:10 +03:00
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
<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
|
2026-06-18 08:43:16 +11:00
|
|
|
&& prev.directory === next.directory
|
2026-04-05 15:36:11 +03:00
|
|
|
&& 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
|
2026-04-05 16:56:05 +03:00
|
|
|
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
2026-04-05 15:36:11 +03:00
|
|
|
&& prev.retryOverlay === next.retryOverlay
|
|
|
|
|
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
|
|
|
|
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
2026-05-30 02:03:41 +03:00
|
|
|
&& prev.handleHistoryScroll === next.handleHistoryScroll
|
2026-04-05 15:36:11 +03:00
|
|
|
&& prev.scrollToBottom === next.scrollToBottom
|
|
|
|
|
&& prev.sessionQuestions === next.sessionQuestions
|
|
|
|
|
&& prev.sessionPermissions === next.sessionPermissions
|
2026-07-03 13:49:53 +03:00
|
|
|
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
|
|
|
|
&& prev.showLoadOlderButton === next.showLoadOlderButton
|
|
|
|
|
&& prev.onLoadOlder === next.onLoadOlder;
|
2026-04-05 15:36:11 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ChatViewport.displayName = 'ChatViewport';
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
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%]'],
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-02-18 20:08:42 +02:00
|
|
|
|
2026-05-15 22:16:04 +03:00
|
|
|
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)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 12:22:59 +03:00
|
|
|
type ChatContainerProps = {
|
|
|
|
|
autoOpenDraft?: boolean;
|
2026-05-15 22:16:04 +03:00
|
|
|
readOnly?: boolean;
|
2026-05-08 12:22:59 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-15 22:16:04 +03:00
|
|
|
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-03-31 18:47:00 +03:00
|
|
|
// Session UI state
|
|
|
|
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
2026-06-03 22:38:15 +03:00
|
|
|
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
2026-03-31 18:47:00 +03:00
|
|
|
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);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// Sync actions
|
|
|
|
|
const sync = useSync();
|
2026-05-21 15:45:15 +03:00
|
|
|
const syncDirectory = useSyncDirectory();
|
2026-06-03 22:38:15 +03:00
|
|
|
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
2026-05-07 18:57:44 +03:00
|
|
|
const ensureSessionRenderable = React.useCallback(
|
|
|
|
|
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
2026-03-31 18:47:00 +03:00
|
|
|
[sync],
|
2026-02-12 20:36:02 +02:00
|
|
|
);
|
2026-03-31 18:47:00 +03:00
|
|
|
const loadMoreMessages = React.useCallback(
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
|
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
|
|
|
|
|
[sync],
|
2026-02-12 20:36:02 +02:00
|
|
|
);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// UI store
|
2026-04-04 02:19:55 +03:00
|
|
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
|
|
|
|
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
2026-07-13 01:06:44 +03:00
|
|
|
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
|
2026-05-05 16:21:06 +08:00
|
|
|
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
|
|
|
|
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
2026-01-02 18:33:35 -05:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Streaming state
|
|
|
|
|
const streamingMessageId = useStreamingStore(
|
2026-02-12 20:36:02 +02:00
|
|
|
React.useCallback(
|
2026-03-31 18:47:00 +03:00
|
|
|
(s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null),
|
|
|
|
|
[currentSessionId],
|
|
|
|
|
),
|
2026-02-12 20:36:02 +02:00
|
|
|
);
|
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-06-03 22:38:15 +03:00
|
|
|
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
2026-05-07 18:57:44 +03:00
|
|
|
const hasRenderableSessionSnapshot = useDirectorySync(
|
2026-04-17 14:33:08 +03:00
|
|
|
React.useCallback(
|
2026-05-07 18:57:44 +03:00
|
|
|
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
2026-04-17 14:33:08 +03:00
|
|
|
[currentSessionId],
|
|
|
|
|
),
|
2026-06-03 22:38:15 +03:00
|
|
|
effectiveSessionDirectory,
|
2026-04-17 14:33:08 +03:00
|
|
|
);
|
2026-03-31 18:47:00 +03:00
|
|
|
// Messages from sync system
|
2026-06-18 08:43:16 +11:00
|
|
|
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
|
|
|
|
suspendPartUpdates: Boolean(streamingMessageId),
|
|
|
|
|
suspendPartUpdatesForMessageId: streamingMessageId,
|
|
|
|
|
});
|
2026-03-31 18:47:00 +03:00
|
|
|
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
2026-05-21 15:45:15 +03:00
|
|
|
const sessionPrefetchInfo = React.useSyncExternalStore(
|
|
|
|
|
React.useCallback(
|
|
|
|
|
(notify) => currentSessionId
|
2026-06-03 22:38:15 +03:00
|
|
|
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
|
2026-05-21 15:45:15 +03:00
|
|
|
: () => undefined,
|
2026-06-03 22:38:15 +03:00
|
|
|
[currentSessionId, effectiveSessionDirectory],
|
2026-05-21 15:45:15 +03:00
|
|
|
),
|
|
|
|
|
React.useCallback(
|
2026-06-03 22:38:15 +03:00
|
|
|
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
|
|
|
|
|
[currentSessionId, effectiveSessionDirectory],
|
2026-05-21 15:45:15 +03:00
|
|
|
),
|
|
|
|
|
React.useCallback(() => undefined, []),
|
|
|
|
|
);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-04-06 20:44:13 +03:00
|
|
|
// Plan detection - watches messages for plan creation and signals store
|
2026-05-21 15:45:15 +03:00
|
|
|
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
2026-04-06 20:44:13 +03:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Session status from sync system
|
2026-06-03 22:38:15 +03:00
|
|
|
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS;
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
// 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);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
const sessionIsWorking = React.useMemo(() => {
|
2026-04-15 01:02:07 +08:00
|
|
|
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
|
2026-04-05 15:36:11 +03:00
|
|
|
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',
|
|
|
|
|
);
|
2026-05-07 18:57:44 +03:00
|
|
|
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type]);
|
2026-04-05 15:36:11 +03:00
|
|
|
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]);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// History metadata — use sync's hasMore/isLoading
|
|
|
|
|
const historyMeta = React.useMemo(() => {
|
|
|
|
|
if (!currentSessionId) return null;
|
2026-07-05 01:54:33 +03:00
|
|
|
// Sync's meta is authoritative once a fetch has confirmed the history
|
|
|
|
|
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
|
|
|
|
|
// the initial page) must not keep the "load older" affordance alive
|
|
|
|
|
// after the user has already reached the top.
|
|
|
|
|
const syncComplete = sync.isComplete(currentSessionId);
|
|
|
|
|
const prefetchHasMore = !syncComplete
|
|
|
|
|
&& Boolean(sessionPrefetchInfo?.cursor)
|
|
|
|
|
&& sessionPrefetchInfo?.complete !== true;
|
2026-03-31 18:47:00 +03:00
|
|
|
return {
|
|
|
|
|
limit: sessionMessages.length,
|
2026-07-05 01:54:33 +03:00
|
|
|
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
2026-03-31 18:47:00 +03:00
|
|
|
loading: sync.isLoading(currentSessionId),
|
|
|
|
|
};
|
2026-05-21 15:45:15 +03:00
|
|
|
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
|
2026-02-12 20:36:02 +02:00
|
|
|
|
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();
|
2025-12-21 20:18:51 +02:00
|
|
|
const draftOpen = Boolean(newSessionDraft?.open);
|
2026-05-30 00:16:28 +03:00
|
|
|
const initError = useGlobalSyncStore((s) => s.error);
|
2026-07-04 16:41:17 +03:00
|
|
|
// 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;
|
2026-05-28 01:20:21 +03:00
|
|
|
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
2026-02-27 20:03:42 +02:00
|
|
|
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
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const handleReturnToParentSession = React.useCallback(() => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!parentSession) return;
|
2026-04-03 12:38:03 +03:00
|
|
|
const parentDirectory = (parentSession as Session & { directory?: string | null }).directory ?? null;
|
|
|
|
|
setCurrentSession(parentSession.id, parentDirectory);
|
2026-02-27 20:45:03 +02:00
|
|
|
}, [parentSession, setCurrentSession]);
|
|
|
|
|
|
|
|
|
|
const returnToParentButton = parentSession ? (
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button
|
2026-02-27 20:45:03 +02:00
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="xs"
|
|
|
|
|
onClick={handleReturnToParentSession}
|
|
|
|
|
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('chat.container.returnToParent.aria')}
|
|
|
|
|
title={parentSession.title?.trim()
|
|
|
|
|
? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
|
|
|
|
|
: t('chat.container.returnToParent.title')}
|
2026-02-27 20:45:03 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-left" className="h-4 w-4" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('chat.container.returnToParent.label')}
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2026-02-27 20:45:03 +02:00
|
|
|
) : null;
|
2026-07-13 01:21:18 +03:00
|
|
|
const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly;
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined' || window.parent === window) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const applySetting = (value: boolean) => {
|
|
|
|
|
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 !== window.parent || 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);
|
|
|
|
|
window.parent.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('message', handleMessage);
|
|
|
|
|
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
|
|
|
|
delete scopedWindow.__openchamberApplyChatSettingsSync;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2025-12-22 01:16:22 +02:00
|
|
|
React.useEffect(() => {
|
2026-05-08 12:22:59 +03:00
|
|
|
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
2025-12-22 01:16:22 +02:00
|
|
|
openNewSessionDraft();
|
|
|
|
|
}
|
2026-05-08 12:22:59 +03:00
|
|
|
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
2025-12-22 01:16:22 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
2026-03-31 18:47:00 +03:00
|
|
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
|
|
|
|
activeTurnChangeRef.current(turnId);
|
|
|
|
|
}, []);
|
2026-03-12 23:45:45 +02:00
|
|
|
|
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,
|
2026-06-27 00:06:19 +03:00
|
|
|
scrollToBottomOnSend,
|
2026-05-08 14:20:16 +03:00
|
|
|
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,
|
2026-04-05 23:01:12 +03:00
|
|
|
sessionIsWorking,
|
2025-12-07 19:32:53 +02:00
|
|
|
isMobile,
|
2026-03-31 18:47:00 +03:00
|
|
|
onActiveTurnChange: handleActiveTurnChange,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-07 20:33:01 +03:00
|
|
|
const viewportMessages = sessionMessages;
|
2026-03-12 23:45:45 +02:00
|
|
|
|
|
|
|
|
const timelineController = useChatTimelineController({
|
|
|
|
|
sessionId: currentSessionId,
|
2026-04-06 20:18:20 +03:00
|
|
|
messages: viewportMessages,
|
2026-03-12 23:45:45 +02:00
|
|
|
historyMeta,
|
|
|
|
|
scrollRef,
|
|
|
|
|
messageListRef,
|
|
|
|
|
loadMoreMessages,
|
2026-05-08 14:20:16 +03:00
|
|
|
goToBottom,
|
|
|
|
|
releaseAutoFollow,
|
2026-03-12 23:45:45 +02:00
|
|
|
isPinned,
|
2026-05-08 14:20:16 +03:00
|
|
|
showScrollButton,
|
2026-03-12 23:45:45 +02:00
|
|
|
});
|
2026-05-08 14:20:16 +03:00
|
|
|
const resumeToLatestInstant = React.useCallback(() => {
|
|
|
|
|
goToBottom('instant');
|
|
|
|
|
}, [goToBottom]);
|
2026-07-03 13:49:53 +03:00
|
|
|
// 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]);
|
2026-04-06 20:18:20 +03:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
|
|
|
|
}, [timelineController.handleActiveTurnChange]);
|
|
|
|
|
|
2026-04-05 15:36:11 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handleMessageContentChange('permission');
|
|
|
|
|
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
|
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const navigation = useChatTurnNavigation({
|
|
|
|
|
sessionId: currentSessionId,
|
|
|
|
|
turnIds: timelineController.turnIds,
|
|
|
|
|
activeTurnId: timelineController.activeTurnId,
|
|
|
|
|
scrollToTurn: timelineController.scrollToTurn,
|
|
|
|
|
scrollToMessage: timelineController.scrollToMessage,
|
2026-04-06 20:18:20 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
const container = scrollRef.current;
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!container) return;
|
2026-03-04 01:41:01 +02:00
|
|
|
|
|
|
|
|
const updateChatScrollHeight = () => {
|
|
|
|
|
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateChatScrollHeight();
|
|
|
|
|
|
2026-03-16 06:18:59 +09:00
|
|
|
let rafId = 0;
|
|
|
|
|
const scheduleUpdate = () => {
|
|
|
|
|
if (rafId) return;
|
|
|
|
|
rafId = requestAnimationFrame(() => {
|
|
|
|
|
rafId = 0;
|
|
|
|
|
updateChatScrollHeight();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-04 01:41:01 +02:00
|
|
|
if (typeof ResizeObserver === 'undefined') {
|
2026-03-16 06:18:59 +09:00
|
|
|
window.addEventListener('resize', scheduleUpdate);
|
2026-03-04 01:41:01 +02:00
|
|
|
return () => {
|
2026-03-16 06:18:59 +09:00
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
|
|
|
window.removeEventListener('resize', scheduleUpdate);
|
2026-03-04 01:41:01 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 06:18:59 +09:00
|
|
|
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
2026-03-04 01:41:01 +02:00
|
|
|
resizeObserver.observe(container);
|
|
|
|
|
|
|
|
|
|
return () => {
|
2026-03-16 06:18:59 +09:00
|
|
|
if (rafId) cancelAnimationFrame(rafId);
|
2026-03-04 01:41:01 +02:00
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
|
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-03-12 23:45:45 +02:00
|
|
|
const isSessionHydrating =
|
|
|
|
|
Boolean(currentSessionId)
|
2026-05-07 18:57:44 +03:00
|
|
|
&& !hasRenderableSessionSnapshot;
|
2026-01-02 18:33:35 -05:00
|
|
|
|
2026-04-06 20:18:20 +03:00
|
|
|
React.useEffect(() => {
|
2026-05-08 14:20:16 +03:00
|
|
|
if (!currentSessionId) return;
|
|
|
|
|
if (lastScrolledSessionRef.current === currentSessionId) return;
|
2026-04-06 20:18:20 +03:00
|
|
|
|
|
|
|
|
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
2026-05-08 14:20:16 +03:00
|
|
|
lastScrolledSessionRef.current = currentSessionId;
|
2026-04-06 20:18:20 +03:00
|
|
|
if (hasHashTarget) {
|
2026-05-08 14:20:16 +03:00
|
|
|
// Hash navigation handler will scroll to target; we just release auto-follow.
|
|
|
|
|
releaseAutoFollow();
|
2026-04-06 20:18:20 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 14:20:16 +03:00
|
|
|
const run = () => {
|
|
|
|
|
void restoreSnapshot();
|
|
|
|
|
};
|
2026-04-06 20:18:20 +03:00
|
|
|
if (typeof window === 'undefined') {
|
2026-05-08 14:20:16 +03:00
|
|
|
run();
|
|
|
|
|
} else {
|
|
|
|
|
window.requestAnimationFrame(run);
|
2026-04-06 20:18:20 +03:00
|
|
|
}
|
2026-05-08 14:20:16 +03:00
|
|
|
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
2026-04-06 20:18:20 +03:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
React.useEffect(() => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!currentSessionId) return;
|
2026-05-07 18:57:44 +03:00
|
|
|
if (hasRenderableSessionSnapshot) return;
|
2026-06-03 22:38:15 +03:00
|
|
|
if (effectiveSessionDirectory !== syncDirectory) return;
|
2026-05-08 14:20:16 +03:00
|
|
|
void ensureSessionRenderable(currentSessionId);
|
2026-06-03 22:38:15 +03:00
|
|
|
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-04-24 09:33:02 +03:00
|
|
|
if (!currentSessionId && !draftOpen) {
|
2026-05-30 00:16:28 +03:00
|
|
|
// 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 <div className="flex h-full flex-col bg-background" />;
|
|
|
|
|
}
|
2026-04-24 09:33:02 +03:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full bg-background">
|
|
|
|
|
<ChatEmptyState />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSessionId && draftOpen) {
|
|
|
|
|
return (
|
2026-07-05 09:14:55 +03:00
|
|
|
// 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).
|
|
|
|
|
<div className="relative flex h-full flex-col bg-background">
|
2026-05-28 01:13:03 +03:00
|
|
|
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
2026-07-05 00:05:38 +03:00
|
|
|
<div className="oc-draft-center flex min-h-0 flex-1 flex-col 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>
|
2026-05-29 13:35:36 +03:00
|
|
|
<DraftPresetChips
|
|
|
|
|
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
2026-07-05 00:05:38 +03:00
|
|
|
className="oc-draft-starters mt-8 max-w-md"
|
2026-05-29 13:35:36 +03:00
|
|
|
/>
|
2026-05-27 23:36:26 +03:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-27 17:35:58 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative z-10 flex min-h-0',
|
2026-04-24 09:33:02 +03:00
|
|
|
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-04-24 09:33:02 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2026-07-13 01:06:44 +03:00
|
|
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
2026-04-24 09:33:02 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-21 20:18:51 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSessionId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-05-07 18:57:44 +03:00
|
|
|
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
2026-04-24 09:33:02 +03:00
|
|
|
return (
|
|
|
|
|
<div className="relative flex flex-col h-full bg-background">
|
|
|
|
|
{returnToParentButton}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative min-h-0',
|
2026-03-23 23:51:55 +02:00
|
|
|
isDesktopExpandedInput
|
|
|
|
|
? 'absolute inset-0 opacity-0 pointer-events-none'
|
|
|
|
|
: 'flex-1'
|
|
|
|
|
)}
|
|
|
|
|
aria-hidden={isDesktopExpandedInput}
|
|
|
|
|
>
|
2026-04-27 16:14:09 +07:00
|
|
|
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-background pt-6" style={CHAT_SCROLL_STYLE}>
|
2026-03-23 23:51:55 +02:00
|
|
|
<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>
|
2026-03-12 23:45:45 +02:00
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-23 23:51:55 +02:00
|
|
|
))}
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-23 23:51:55 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative z-10',
|
2026-04-24 09:33:02 +03:00
|
|
|
isDesktopExpandedInput
|
|
|
|
|
? 'flex-1 min-h-0 bg-background'
|
|
|
|
|
: 'bg-background'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-07-13 01:06:44 +03:00
|
|
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
2026-04-24 09:33:02 +03:00
|
|
|
</div>
|
2026-03-12 23:45:45 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 18:57:44 +03:00
|
|
|
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
2026-04-24 09:33:02 +03:00
|
|
|
return (
|
2026-07-05 09:14:55 +03:00
|
|
|
// No transform here either — same fixed-positioning constraint as the
|
|
|
|
|
// draft branch above.
|
|
|
|
|
<div className="relative flex flex-col h-full bg-background">
|
2026-04-24 09:33:02 +03:00
|
|
|
{returnToParentButton}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-03-23 23:51:55 +02:00
|
|
|
'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>
|
2026-02-23 05:04:25 +07:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative z-10',
|
2026-04-24 09:33:02 +03:00
|
|
|
isDesktopExpandedInput
|
|
|
|
|
? 'flex-1 min-h-0 bg-background'
|
|
|
|
|
: 'bg-background'
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-07-13 01:06:44 +03:00
|
|
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
2026-04-24 09:33:02 +03:00
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 09:33:02 +03:00
|
|
|
return (
|
|
|
|
|
<div className="relative flex flex-col h-full bg-background">
|
|
|
|
|
{returnToParentButton}
|
|
|
|
|
<ChatViewport
|
2026-05-21 15:45:15 +03:00
|
|
|
key={currentSessionId}
|
2026-04-24 09:33:02 +03:00
|
|
|
currentSessionId={currentSessionId}
|
2026-04-05 15:36:11 +03:00
|
|
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
stickyUserHeader={stickyUserHeader}
|
2026-06-18 08:43:16 +11:00
|
|
|
directory={effectiveSessionDirectory}
|
2026-04-05 15:36:11 +03:00
|
|
|
scrollRef={scrollRef}
|
|
|
|
|
messageListRef={messageListRef}
|
|
|
|
|
pendingRevealWork={timelineController.pendingRevealWork}
|
|
|
|
|
renderedMessages={timelineController.renderedMessages}
|
|
|
|
|
isLoadingOlder={timelineController.isLoadingOlder}
|
|
|
|
|
sessionIsWorking={sessionIsWorking}
|
|
|
|
|
streamingMessageId={streamingMessageId}
|
2026-04-05 16:56:05 +03:00
|
|
|
activeStreamingPhase={activeStreamingPhase}
|
2026-04-05 15:36:11 +03:00
|
|
|
retryOverlay={retryOverlay}
|
|
|
|
|
handleMessageContentChange={handleMessageContentChange}
|
|
|
|
|
getAnimationHandlers={getAnimationHandlers}
|
2026-05-30 02:03:41 +03:00
|
|
|
handleHistoryScroll={timelineController.handleHistoryScroll}
|
2026-05-08 14:20:16 +03:00
|
|
|
scrollToBottom={resumeToLatestInstant}
|
2026-04-05 15:36:11 +03:00
|
|
|
sessionQuestions={sessionQuestions}
|
|
|
|
|
sessionPermissions={sessionPermissions}
|
2026-05-08 14:20:16 +03:00
|
|
|
isProgrammaticFollowActive={isFollowingProgrammatically}
|
2026-07-03 13:49:53 +03:00
|
|
|
showLoadOlderButton={showLoadOlderButton}
|
|
|
|
|
onLoadOlder={handleLoadOlderClick}
|
2026-04-05 15:36:11 +03:00
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-23 05:04:25 +07:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative z-10',
|
2026-05-05 16:21:06 +08:00
|
|
|
isDesktopExpandedInput
|
|
|
|
|
? 'flex-1 min-h-0 bg-background'
|
|
|
|
|
: 'bg-background'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
|
|
|
|
<ScrollToBottomButton
|
2026-03-12 23:45:45 +02:00
|
|
|
visible={timelineController.showScrollToBottom}
|
|
|
|
|
onClick={navigation.resumeToLatest}
|
|
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
)}
|
2026-06-27 00:06:19 +03:00
|
|
|
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
2026-05-05 16:21:06 +08:00
|
|
|
|
|
|
|
|
<TimelineDialog
|
|
|
|
|
open={isTimelineDialogOpen}
|
|
|
|
|
onOpenChange={setTimelineDialogOpen}
|
|
|
|
|
onScrollToMessage={timelineController.scrollToMessage}
|
|
|
|
|
onScrollByTurnOffset={navigation.scrollByTurnOffset}
|
|
|
|
|
onResumeToLatest={resumeToLatestInstant}
|
2026-07-06 17:24:01 +03:00
|
|
|
canLoadEarlier={timelineController.historySignals.canLoadEarlier}
|
|
|
|
|
isLoadingEarlier={timelineController.isLoadingOlder}
|
|
|
|
|
onLoadEarlier={handleLoadOlderClick}
|
2026-05-05 16:21:06 +08:00
|
|
|
/>
|
2025-12-07 19:32:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|