Files
openchamber/packages/ui/src/components/chat/ChatContainer.tsx
T
Bohdan Triapitsyn bb72c0fb0c feat: vscode extension (#59)
* feat: add initial VS Code extension plan and implementation tasks

* feat(vscode): added initial version of an Openchamber VSCode extension

* feat(vscode): enhance VS Code extension with theme integration and session management

* feat(vscode): implement connection status handling and overlay in VSCode layout

* feat: move extension to secondary sidebar

* chore: upgrade @opencode-ai/sdk to 1.0.150

* vscode: editor bridge, file picker, click-to-open in tool parts

* vscode: layout session lifecycle, theme sync, typography overrides

* ui: compact mode for vscode, model search, autocomplete width fixes

* perf: scroll force flag, raf placeholder, git polling backoff

* ui: tool output styling, markdown code block fix, gitignore

* refactor: update typography handling for VSCode runtime, remove unused styles

* docs: update README with VS Code extension details and add extension image

* docs: update changelog with new features and performance improvements
2025-12-13 16:34:17 +02:00

256 lines
9.6 KiB
TypeScript

import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore';
import { Skeleton } from '@/components/ui/skeleton';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import MessageList from './MessageList';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
export const ChatContainer: React.FC = () => {
const {
currentSessionId,
messages,
permissions,
streamingMessageIds,
isLoading,
loadMessages,
loadMoreMessages,
updateViewportAnchor,
sessionMemoryState,
isSyncing,
messageStreamStates,
trimToViewportWindow,
sessionActivityPhase,
} = useSessionStore();
const streamingMessageId = React.useMemo(() => {
if (!currentSessionId) return null;
return streamingMessageIds.get(currentSessionId) ?? null;
}, [currentSessionId, streamingMessageIds]);
const { isMobile } = useDeviceInfo();
const sessionMessages = React.useMemo(() => {
return currentSessionId ? messages.get(currentSessionId) || [] : [];
}, [currentSessionId, messages]);
const sessionPermissions = React.useMemo(() => {
return currentSessionId ? permissions.get(currentSessionId) || [] : [];
}, [currentSessionId, permissions]);
const {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
spacerHeight,
pendingAnchorId,
hasActiveAnchor,
} = useChatScrollManager({
currentSessionId,
sessionMessages,
streamingMessageId,
sessionMemoryState,
updateViewportAnchor,
isSyncing,
isMobile,
messageStreamStates,
sessionPermissions,
trimToViewportWindow,
sessionActivityPhase,
});
const memoryState = React.useMemo(() => {
if (!currentSessionId) {
return null;
}
return sessionMemoryState.get(currentSessionId) ?? null;
}, [currentSessionId, sessionMemoryState]);
const hasMoreAbove = Boolean(memoryState?.hasMoreAbove);
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
React.useEffect(() => {
setIsLoadingOlder(false);
}, [currentSessionId]);
const lastScrolledSessionRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
if (!currentSessionId || currentSessionId === lastScrolledSessionRef.current) {
return;
}
lastScrolledSessionRef.current = currentSessionId;
const container = scrollRef.current;
if (container) {
container.scrollTop = container.scrollHeight - container.clientHeight;
}
}, [currentSessionId, scrollRef]);
const handleLoadOlder = React.useCallback(async () => {
if (!currentSessionId || isLoadingOlder) {
return;
}
const container = scrollRef.current;
const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null;
setIsLoadingOlder(true);
try {
await loadMoreMessages(currentSessionId, 'up');
if (container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight;
container.scrollTop = prevTop + heightDiff;
}
} finally {
setIsLoadingOlder(false);
}
}, [currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef]);
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const hasSessionMessages = messages.has(currentSessionId);
const existingMessages = hasSessionMessages ? messages.get(currentSessionId) ?? [] : [];
if (existingMessages.length > 0) {
return;
}
const load = async () => {
try {
await loadMessages(currentSessionId);
} finally {
if (typeof window === 'undefined') {
scrollToBottom();
} else {
window.requestAnimationFrame(() => {
scrollToBottom();
});
}
}
};
void load();
}, [currentSessionId, loadMessages, messages, scrollToBottom]);
if (!currentSessionId) {
return (
<div className="flex flex-col h-full bg-background">
<div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div>
</div>
);
}
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = messages.has(currentSessionId);
if (!hasMessagesEntry) {
return (
<div className="flex flex-col h-full bg-background gap-0">
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-column space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 p-4">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
</div>
</div>
))}
</div>
</div>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
);
}
}
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div className="flex flex-col h-full bg-background transform-gpu">
<div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full bg-background">
<div className="relative flex-1 min-h-0">
<div className="absolute inset-0">
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={{
contain: 'strict',
['--scroll-shadow-size' as string]: '48px',
}}
data-scroll-shadow="true"
data-scrollbar="chat"
hideBottomShadow={!!pendingAnchorId}
>
<div className="relative z-0 min-h-full">
<MessageList
messages={sessionMessages}
permissions={sessionPermissions}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAbove}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
scrollToBottom={scrollToBottom}
pendingAnchorId={pendingAnchorId}
/>
{}
{spacerHeight > 0 && hasActiveAnchor && (
<div
data-role="active-turn-spacer"
style={{ height: spacerHeight }}
aria-hidden="true"
/>
)}
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} />
</div>
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
{showScrollButton && sessionMessages.length > 0 && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
<Button
variant="outline"
size="sm"
onClick={() => scrollToBottom({ force: true })}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-accent"
aria-label="Scroll to bottom"
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
</div>
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
};