Files
openchamber/packages/ui/src/components/layout/VSCodeLayout.tsx
T

237 lines
8.4 KiB
TypeScript
Raw Normal View History

2025-12-13 16:34:17 +02:00
import React from 'react';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { ChatView } from '@/components/views';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { RiAddLine, RiArrowLeftLine, RiSettings3Line } from '@remixicon/react';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
2025-12-13 16:34:17 +02:00
type VSCodeView = 'sessions' | 'chat' | 'settings';
2025-12-13 16:34:17 +02:00
export const VSCodeLayout: React.FC = () => {
const [currentView, setCurrentView] = React.useState<VSCodeView>('chat');
2025-12-13 16:34:17 +02:00
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
2025-12-13 16:34:17 +02:00
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
: 'connecting') || 'connecting'
);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeConfig = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
const loadMessages = useSessionStore((state) => state.loadMessages);
const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
// Navigate to chat when a session is selected
React.useEffect(() => {
if (currentSessionId) {
setCurrentView('chat');
}
}, [currentSessionId]);
// If the active session disappears (e.g., deleted), show a new chat view
2025-12-13 16:34:17 +02:00
React.useEffect(() => {
if (!currentSessionId && !newSessionDraftOpen) {
openNewSessionDraft();
2025-12-13 16:34:17 +02:00
}
}, [currentSessionId, newSessionDraftOpen, openNewSessionDraft]);
2025-12-13 16:34:17 +02:00
const handleBackToSessions = React.useCallback(() => {
setCurrentView('sessions');
}, []);
const handleNewSession = React.useCallback(() => {
openNewSessionDraft();
setCurrentView('chat');
}, [openNewSessionDraft]);
2025-12-13 16:34:17 +02:00
// Listen for connection status changes
2025-12-13 16:34:17 +02:00
React.useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status;
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
setConnectionStatus(status);
}
};
window.addEventListener('openchamber:connection-status', handler as EventListener);
return () => window.removeEventListener('openchamber:connection-status', handler as EventListener);
}, []);
// Bootstrap config and sessions when connected
2025-12-13 16:34:17 +02:00
React.useEffect(() => {
const runBootstrap = async () => {
if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') {
return;
}
setIsInitializing(true);
try {
if (!configInitialized) {
await initializeConfig();
}
await loadSessions();
setHasInitializedOnce(true);
} catch {
// Ignore bootstrap failures
2025-12-13 16:34:17 +02:00
} finally {
setIsInitializing(false);
}
};
void runBootstrap();
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
// Hydrate messages when viewing chat
2025-12-13 16:34:17 +02:00
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
return;
}
if (!currentSessionId) {
2025-12-13 16:34:17 +02:00
return;
}
const hasMessages = messages.has(currentSessionId) && (messages.get(currentSessionId)?.length || 0) > 0;
2025-12-13 16:34:17 +02:00
if (!hasMessages) {
try {
await loadMessages(currentSessionId);
2025-12-13 16:34:17 +02:00
} catch { /* ignored */ }
}
};
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
2025-12-13 16:34:17 +02:00
return (
<div className="h-full w-full bg-background text-foreground flex flex-col">
{currentView === 'sessions' ? (
<div className="flex flex-col h-full">
<VSCodeHeader
title="Sessions"
onNewSession={handleNewSession}
onSettings={() => setCurrentView('settings')}
/>
2025-12-13 16:34:17 +02:00
<div className="flex-1 overflow-hidden">
<SessionSidebar
mobileVariant
allowReselect
onSessionSelected={() => setCurrentView('chat')}
hideDirectoryControls
/>
</div>
</div>
) : currentView === 'settings' ? (
<div className="flex flex-col h-full">
<VSCodeHeader
title="Settings"
showBack
onBack={handleBackToSessions}
/>
<div className="flex-1 overflow-y-auto">
<VSCodeSettingsView />
</div>
</div>
2025-12-13 16:34:17 +02:00
) : (
<div className="flex flex-col h-full">
<VSCodeHeader
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
2025-12-13 16:34:17 +02:00
showBack
onBack={handleBackToSessions}
showContextUsage
/>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</div>
)}
</div>
);
};
interface VSCodeHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onNewSession?: () => void;
onSettings?: () => void;
2025-12-13 16:34:17 +02:00
showContextUsage?: boolean;
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, showContextUsage }) => {
2025-12-13 16:34:17 +02:00
const { getCurrentModel } = useConfigStore();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const currentModel = getCurrentModel();
const limits = (currentModel?.limit && typeof currentModel.limit === 'object'
? currentModel.limit
: null) as { context?: number; output?: number } | null;
const contextLimit = typeof limits?.context === 'number' ? limits.context : 0;
const outputLimit = typeof limits?.output === 'number' ? limits.output : 0;
const contextUsage = getContextUsage(contextLimit, outputLimit);
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-background shrink-0">
{showBack && onBack && (
<button
onClick={onBack}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Back to sessions"
>
<RiArrowLeftLine className="h-5 w-5" />
</button>
)}
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
{onNewSession && (
<button
onClick={onNewSession}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="New session"
>
<RiAddLine className="h-5 w-5" />
</button>
)}
{onSettings && (
<button
onClick={onSettings}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Settings"
>
<RiSettings3Line className="h-5 w-5" />
</button>
)}
2025-12-13 16:34:17 +02:00
{showContextUsage && contextUsage && contextUsage.totalTokens > 0 && (
<ContextUsageDisplay
totalTokens={contextUsage.totalTokens}
percentage={contextUsage.percentage}
contextLimit={contextUsage.contextLimit}
outputLimit={contextUsage.outputLimit ?? 0}
size="compact"
/>
)}
</div>
);
};
const VSCodeSettingsView: React.FC = () => {
return (
<div className="flex flex-col h-full">
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<OpenChamberPage />
</div>
</div>
);
};