* 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
256 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
};
|