2025-12-13 16:34:17 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
|
|
|
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
2025-12-28 03:28:13 +02:00
|
|
|
import { ChatView, SettingsView } from '@/components/views';
|
2025-12-13 16:34:17 +02:00
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
|
|
|
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
2026-01-08 23:14:29 +02:00
|
|
|
import { McpDropdown } from '@/components/mcp/McpDropdown';
|
2026-01-03 22:11:16 +01:00
|
|
|
import { RiAddLine, RiArrowLeftLine, RiRobot2Line, RiSettings3Line } from '@remixicon/react';
|
2025-12-28 03:28:13 +02:00
|
|
|
|
|
|
|
|
// Width threshold for mobile vs desktop layout in settings
|
|
|
|
|
const MOBILE_WIDTH_THRESHOLD = 550;
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2025-12-15 18:11:38 +02:00
|
|
|
type VSCodeView = 'sessions' | 'chat' | 'settings';
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
export const VSCodeLayout: React.FC = () => {
|
2025-12-21 20:18:51 +02:00
|
|
|
const [currentView, setCurrentView] = React.useState<VSCodeView>('chat');
|
2025-12-28 03:28:13 +02:00
|
|
|
const [containerWidth, setContainerWidth] = React.useState<number>(0);
|
|
|
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
2025-12-13 16:34:17 +02:00
|
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
|
|
|
const sessions = useSessionStore((state) => state.sessions);
|
2025-12-21 20:18:51 +02:00
|
|
|
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);
|
2026-01-06 21:31:04 +02:00
|
|
|
const lastBootstrapAttemptAt = React.useRef<number>(0);
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
// Navigate to chat when a session is selected
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (currentSessionId) {
|
|
|
|
|
setCurrentView('chat');
|
|
|
|
|
}
|
|
|
|
|
}, [currentSessionId]);
|
|
|
|
|
|
2025-12-22 01:29:11 +02:00
|
|
|
// If the active session disappears (e.g., deleted), show a new chat view
|
2025-12-13 16:34:17 +02:00
|
|
|
React.useEffect(() => {
|
2025-12-22 01:29:11 +02:00
|
|
|
if (!currentSessionId && !newSessionDraftOpen) {
|
|
|
|
|
openNewSessionDraft();
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
2025-12-22 01:29:11 +02:00
|
|
|
}, [currentSessionId, newSessionDraftOpen, openNewSessionDraft]);
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
const handleBackToSessions = React.useCallback(() => {
|
|
|
|
|
setCurrentView('sessions');
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Listen for connection status changes
|
2025-12-13 16:34:17 +02:00
|
|
|
React.useEffect(() => {
|
2026-01-06 21:31:04 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-31 16:16:03 +01:00
|
|
|
// 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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastBootstrapAttemptAt.current < 750) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastBootstrapAttemptAt.current = now;
|
2025-12-13 16:34:17 +02:00
|
|
|
setIsInitializing(true);
|
|
|
|
|
try {
|
2026-01-06 21:31:04 +02:00
|
|
|
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 });
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!configInitialized) {
|
|
|
|
|
await initializeConfig();
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
await loadSessions();
|
2026-01-06 21:31:04 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
setHasInitializedOnce(true);
|
|
|
|
|
} catch {
|
2025-12-24 03:31:07 +02:00
|
|
|
// Ignore bootstrap failures
|
2025-12-13 16:34:17 +02:00
|
|
|
} finally {
|
|
|
|
|
setIsInitializing(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
void runBootstrap();
|
|
|
|
|
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Hydrate messages when viewing chat
|
2025-12-13 16:34:17 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
const hydrateMessages = async () => {
|
2025-12-21 20:18:51 +02:00
|
|
|
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentSessionId) {
|
2025-12-13 16:34:17 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 20:18:51 +02:00
|
|
|
const hasMessages = messages.has(currentSessionId) && (messages.get(currentSessionId)?.length || 0) > 0;
|
2025-12-13 16:34:17 +02:00
|
|
|
if (!hasMessages) {
|
|
|
|
|
try {
|
2025-12-21 20:18:51 +02:00
|
|
|
await loadMessages(currentSessionId);
|
2025-12-13 16:34:17 +02:00
|
|
|
} catch { /* ignored */ }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void hydrateMessages();
|
2025-12-21 20:18:51 +02:00
|
|
|
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2025-12-28 03:28:13 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
return (
|
2025-12-28 03:28:13 +02:00
|
|
|
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
|
2025-12-13 16:34:17 +02:00
|
|
|
{currentView === 'sessions' ? (
|
|
|
|
|
<div className="flex flex-col h-full">
|
2025-12-15 18:11:38 +02:00
|
|
|
<VSCodeHeader
|
|
|
|
|
title="Sessions"
|
|
|
|
|
/>
|
2025-12-13 16:34:17 +02:00
|
|
|
<div className="flex-1 overflow-hidden">
|
|
|
|
|
<SessionSidebar
|
|
|
|
|
mobileVariant
|
|
|
|
|
allowReselect
|
|
|
|
|
onSessionSelected={() => setCurrentView('chat')}
|
|
|
|
|
hideDirectoryControls
|
2026-01-01 20:39:05 +01:00
|
|
|
showOnlyMainWorkspace
|
2025-12-13 16:34:17 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-15 18:11:38 +02:00
|
|
|
) : currentView === 'settings' ? (
|
2025-12-28 03:28:13 +02:00
|
|
|
<SettingsView
|
|
|
|
|
onClose={() => setCurrentView('sessions')}
|
|
|
|
|
forceMobile={usesMobileLayout}
|
|
|
|
|
/>
|
2025-12-13 16:34:17 +02:00
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col h-full">
|
|
|
|
|
<VSCodeHeader
|
2025-12-21 20:18:51 +02:00
|
|
|
title={newSessionDraftOpen && !currentSessionId
|
|
|
|
|
? 'New session'
|
|
|
|
|
: sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
|
2025-12-13 16:34:17 +02:00
|
|
|
showBack
|
|
|
|
|
onBack={handleBackToSessions}
|
2026-01-08 23:14:29 +02:00
|
|
|
showMcp
|
2025-12-13 16:34:17 +02:00
|
|
|
showContextUsage
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<ChatView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface VSCodeHeaderProps {
|
|
|
|
|
title: string;
|
|
|
|
|
showBack?: boolean;
|
|
|
|
|
onBack?: () => void;
|
|
|
|
|
onNewSession?: () => void;
|
2025-12-15 18:11:38 +02:00
|
|
|
onSettings?: () => void;
|
2026-01-03 22:11:16 +01:00
|
|
|
onAgentManager?: () => void;
|
2026-01-08 23:14:29 +02:00
|
|
|
showMcp?: boolean;
|
2025-12-13 16:34:17 +02:00
|
|
|
showContextUsage?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 23:14:29 +02:00
|
|
|
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, 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}
|
2025-12-28 03:28:13 +02:00
|
|
|
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
2025-12-13 16:34:17 +02:00
|
|
|
aria-label="Back to sessions"
|
|
|
|
|
>
|
|
|
|
|
<RiArrowLeftLine className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2025-12-31 16:16:03 +01:00
|
|
|
<h1 className="text-sm font-medium truncate flex-1 h-9 w-9 items-center justify-center p-2" title={title}>{title}</h1>
|
2025-12-13 16:34:17 +02:00
|
|
|
{onNewSession && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onNewSession}
|
2025-12-28 03:28:13 +02:00
|
|
|
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
2025-12-13 16:34:17 +02:00
|
|
|
aria-label="New session"
|
|
|
|
|
>
|
|
|
|
|
<RiAddLine className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-01-03 22:11:16 +01:00
|
|
|
{onAgentManager && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onAgentManager}
|
|
|
|
|
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label="Open Agent Manager"
|
|
|
|
|
>
|
|
|
|
|
<RiRobot2Line className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-01-08 23:14:29 +02:00
|
|
|
{showMcp && (
|
|
|
|
|
<McpDropdown
|
|
|
|
|
headerIconButtonClass="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2025-12-15 18:11:38 +02:00
|
|
|
{onSettings && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onSettings}
|
2025-12-28 03:28:13 +02:00
|
|
|
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
2025-12-15 18:11:38 +02:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
};
|