import React from 'react'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { ChatView, SettingsView } from '@/components/views'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; import { cn } from '@/lib/utils'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { updateDesktopSettings } from '@/lib/persistence'; import type { UsageWindow } from '@/types'; import { RiAddLine, RiArrowLeftLine, RiRefreshLine, RiRobot2Line, RiSettings3Line, RiTimerLine } from '@remixicon/react'; const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; try { return new Date(timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', }); } catch { return '-'; } }; // Width threshold for mobile vs desktop layout in settings const MOBILE_WIDTH_THRESHOLD = 550; // Width threshold for expanded layout (sidebar + chat side by side) const EXPANDED_LAYOUT_THRESHOLD = 1400; // Sessions sidebar width in expanded layout const SESSIONS_SIDEBAR_WIDTH = 280; type VSCodeView = 'sessions' | 'chat' | 'settings'; export const VSCodeLayout: React.FC = () => { const runtimeApis = useRuntimeAPIs(); const viewMode = React.useMemo<'sidebar' | 'editor'>(() => { const configured = typeof window !== 'undefined' ? (window as unknown as { __VSCODE_CONFIG__?: { viewMode?: unknown } }).__VSCODE_CONFIG__?.viewMode : null; return configured === 'editor' ? 'editor' : 'sidebar'; }, []); const initialSessionId = React.useMemo(() => { const configured = typeof window !== 'undefined' ? (window as unknown as { __VSCODE_CONFIG__?: { initialSessionId?: unknown } }).__VSCODE_CONFIG__?.initialSessionId : null; if (typeof configured === 'string' && configured.trim().length > 0) { return configured.trim(); } return null; }, []); const hasAppliedInitialSession = React.useRef(false); const bootDraftOpen = React.useMemo(() => { try { return Boolean(useSessionStore.getState().newSessionDraft?.open); } catch { return false; } }, []); const [currentView, setCurrentView] = React.useState(() => (bootDraftOpen ? 'chat' : 'sessions')); const [containerWidth, setContainerWidth] = React.useState(0); const containerRef = React.useRef(null); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessions = useSessionStore((state) => state.sessions); const activeSessionTitle = React.useMemo(() => { if (!currentSessionId) { return null; } return sessions.find((session) => session.id === currentSessionId)?.title || 'Session'; }, [currentSessionId, sessions]); const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open)); const isSyncingMessages = useSessionStore((state) => state.isSyncing); const hasActiveSessionWork = useSessionStore((state) => { const statuses = state.sessionStatus; if (!statuses || statuses.size === 0) { return false; } for (const status of statuses.values()) { if (status?.type === 'busy' || status?.type === 'retry') { return true; } } return false; }); const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft); 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(() => configInitialized); const [isInitializing, setIsInitializing] = React.useState(false); const lastBootstrapAttemptAt = React.useRef(0); // Navigate to chat when a session is selected React.useEffect(() => { if (currentSessionId) { setCurrentView('chat'); } }, [currentSessionId]); React.useEffect(() => { const vscodeApi = runtimeApis.vscode; if (!vscodeApi) { return; } void vscodeApi.executeCommand('openchamber.setActiveSession', currentSessionId, activeSessionTitle); }, [activeSessionTitle, currentSessionId, runtimeApis.vscode]); // If the active session disappears (e.g., deleted), go back to sessions list React.useEffect(() => { if (viewMode === 'editor') { return; } if (currentView !== 'chat') { return; } if (currentSessionId || newSessionDraftOpen || isSyncingMessages || hasActiveSessionWork) { return; } const timeoutId = window.setTimeout(() => { const state = useSessionStore.getState(); const stillNoSession = !state.currentSessionId; const draftStillClosed = !state.newSessionDraft?.open; const stillSyncing = state.isSyncing; const stillActiveWork = (() => { const statuses = state.sessionStatus; if (!statuses || statuses.size === 0) return false; for (const status of statuses.values()) { if (status?.type === 'busy' || status?.type === 'retry') return true; } return false; })(); if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) { setCurrentView('sessions'); } }, 900); return () => { window.clearTimeout(timeoutId); }; }, [currentSessionId, newSessionDraftOpen, currentView, viewMode, isSyncingMessages, hasActiveSessionWork]); const handleBackToSessions = React.useCallback(() => { setCurrentView('sessions'); }, []); // Listen for connection status changes React.useEffect(() => { // Catch up with the latest status even if the extension posted the connection message // before this component registered the event listener. const current = (typeof window !== 'undefined' ? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status : undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined; if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') { setConnectionStatus(current); } 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); }, []); // Listen for navigation events from VS Code extension title bar buttons React.useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ view?: string }>).detail; const view = detail?.view; if (view === 'settings') { setCurrentView('settings'); } else if (view === 'chat') { setCurrentView('chat'); } else if (view === 'sessions') { setCurrentView('sessions'); } }; window.addEventListener('openchamber:navigate', handler as EventListener); return () => window.removeEventListener('openchamber:navigate', handler as EventListener); }, []); // Bootstrap config and sessions when connected React.useEffect(() => { const runBootstrap = async () => { if (isInitializing || hasInitializedOnce || connectionStatus !== 'connected') { return; } const now = Date.now(); if (now - lastBootstrapAttemptAt.current < 750) { return; } lastBootstrapAttemptAt.current = now; setIsInitializing(true); try { const debugEnabled = (() => { if (typeof window === 'undefined') return false; try { return window.localStorage.getItem('openchamber_stream_debug') === '1'; } catch { return false; } })(); if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] attempt', { configInitialized }); if (!configInitialized) { await initializeConfig(); } const configStore = useConfigStore.getState(); // Keep trying to fetch core datasets on cold starts. if (configStore.isConnected) { if (configStore.providers.length === 0) { await configStore.loadProviders(); } if (configStore.agents.length === 0) { await configStore.loadAgents(); } } const configState = useConfigStore.getState(); // If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries. // Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts. if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) { return; } await loadSessions(); const sessionsError = useSessionStore.getState().error; if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', { providers: configState.providers.length, agents: configState.agents.length, sessions: useSessionStore.getState().sessions.length, sessionsError, }); if (typeof sessionsError === 'string' && sessionsError.length > 0) { return; } setHasInitializedOnce(true); } catch { // Ignore bootstrap failures } finally { setIsInitializing(false); } }; void runBootstrap(); }, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]); React.useEffect(() => { if (viewMode !== 'editor') { return; } if (hasAppliedInitialSession.current) { return; } if (!hasInitializedOnce || connectionStatus !== 'connected') { return; } // No initialSessionId means open a new session draft if (!initialSessionId) { hasAppliedInitialSession.current = true; openNewSessionDraft(); return; } if (!sessions.some((session) => session.id === initialSessionId)) { return; } hasAppliedInitialSession.current = true; void useSessionStore.getState().setCurrentSession(initialSessionId); }, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]); // Hydrate messages when viewing chat React.useEffect(() => { const hydrateMessages = async () => { if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) { return; } if (!currentSessionId) { return; } const hasMessagesEntry = messages.has(currentSessionId); if (hasMessagesEntry) { return; } try { await loadMessages(currentSessionId); } catch { /* ignored */ } }; void hydrateMessages(); }, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]); // Track container width for responsive settings layout React.useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setContainerWidth(entry.contentRect.width); } }); observer.observe(container); // Set initial width setContainerWidth(container.clientWidth); return () => observer.disconnect(); }, []); const usesMobileLayout = containerWidth > 0 && containerWidth < MOBILE_WIDTH_THRESHOLD; const usesExpandedLayout = containerWidth >= EXPANDED_LAYOUT_THRESHOLD; // In expanded layout, always show chat (with sidebar alongside) // Navigate to chat automatically when expanded layout is enabled and we're on sessions view React.useEffect(() => { if (usesExpandedLayout && currentView === 'sessions' && viewMode === 'sidebar') { setCurrentView('chat'); } }, [usesExpandedLayout, currentView, viewMode]); return (
{viewMode === 'editor' ? ( // Editor mode: just chat, no sidebar
session.id === currentSessionId)?.title || 'Chat'} showMcp showContextUsage />
) : currentView === 'settings' ? ( // Settings view setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')} forceMobile={usesMobileLayout} /> ) : usesExpandedLayout ? ( // Expanded layout: sessions sidebar + chat side by side
{/* Sessions sidebar */}
{/* Chat content */}
session.id === currentSessionId)?.title || 'Chat'} showMcp showContextUsage />
) : ( // Compact layout: drill-down between sessions list and chat <> {/* Sessions list view */}
setCurrentView('chat')} hideDirectoryControls showOnlyMainWorkspace />
{/* Chat view */}
session.id === currentSessionId)?.title || 'Chat'} showBack onBack={handleBackToSessions} showMcp showContextUsage showRateLimits />
)}
); }; interface VSCodeHeaderProps { title: string; showBack?: boolean; onBack?: () => void; onNewSession?: () => void; onSettings?: () => void; onAgentManager?: () => void; showMcp?: boolean; showContextUsage?: boolean; showRateLimits?: boolean; } const VSCodeHeader: React.FC = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => { const { getCurrentModel } = useConfigStore(); const getContextUsage = useSessionStore((state) => state.getContextUsage); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); useQuotaAutoRefresh(); React.useEffect(() => { void loadQuotaSettings(); }, [loadQuotaSettings]); 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); const rateLimitGroups = React.useMemo(() => { const groups: Array<{ providerId: string; providerName: string; entries: Array<[string, UsageWindow]>; error?: string; }> = []; for (const provider of QUOTA_PROVIDERS) { if (!dropdownProviderIds.includes(provider.id)) { continue; } const result = quotaResults.find((entry) => entry.providerId === provider.id); const windows = (result?.usage?.windows ?? {}) as Record; const entries = Object.entries(windows); const error = (result && !result.ok && result.configured) ? result.error : undefined; if (entries.length > 0 || error) { groups.push({ providerId: provider.id, providerName: provider.name, entries, error }); } } return groups; }, [dropdownProviderIds, quotaResults]); const hasRateLimits = rateLimitGroups.length > 0; const handleDisplayModeChange = React.useCallback(async (mode: 'usage' | 'remaining') => { setQuotaDisplayMode(mode); try { await updateDesktopSettings({ usageDisplayMode: mode }); } catch (error) { console.warn('Failed to update usage display mode:', error); } }, [setQuotaDisplayMode]); return (
{showBack && onBack && ( )}

{title}

{onNewSession && ( )} {onAgentManager && ( )} {showMcp && ( )} {showRateLimits && ( { if (open && quotaResults.length === 0) { fetchAllQuotas(); } }} >
Rate limits
Last updated {formatTime(quotaLastUpdated)}
{!hasRateLimits && ( event.preventDefault()}> No rate limits available. )} {rateLimitGroups.map((group, index) => ( {group.providerName} {group.entries.length === 0 ? ( event.preventDefault()} > {group.error ?? 'No rate limits reported.'} ) : ( group.entries.map(([label, window]) => { const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); const expectedMarker = paceInfo?.dailyAllocationPercent != null ? (quotaDisplayMode === 'remaining' ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) : null; return ( event.preventDefault()} > {formatWindowLabel(label)} {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} {paceInfo && (
)} {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''}
); }) )} {index < rateLimitGroups.length - 1 && }
))}
)} {onSettings && ( )} {showContextUsage && contextUsage && contextUsage.totalTokens > 0 && ( )}
); };