import React from 'react'; import { RiArrowLeftRightLine, RiChat4Line, RiCloseLine, RiDonutChartFill, RiFileTextLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { DiffView, FilesView, PlanView } from '@/components/views'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore } from '@/stores/useUIStore'; import { ContextPanelContent } from './ContextSidebarTab'; const CONTEXT_PANEL_MIN_WIDTH = 360; const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; const normalizeDirectoryKey = (value: string): string => { if (!value) return ''; const raw = value.replace(/\\/g, '/'); const hadUncPrefix = raw.startsWith('//'); let normalized = raw.replace(/\/+$/g, ''); normalized = normalized.replace(/\/+/g, '/'); if (hadUncPrefix && !normalized.startsWith('//')) { normalized = `/${normalized}`; } if (normalized === '') { return raw.startsWith('/') ? '/' : ''; } return normalized; }; const clampWidth = (width: number): number => { if (!Number.isFinite(width)) { return CONTEXT_PANEL_DEFAULT_WIDTH; } return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width))); }; const getRelativePathLabel = (filePath: string | null, directory: string): string => { if (!filePath) { return ''; } const normalizedFile = filePath.replace(/\\/g, '/'); const normalizedDir = directory.replace(/\\/g, '/').replace(/\/+$/, ''); if (normalizedDir && normalizedFile.startsWith(normalizedDir + '/')) { return normalizedFile.slice(normalizedDir.length + 1); } return normalizedFile; }; const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => { if (mode === 'chat') return 'Chat'; if (mode === 'file') return 'Files'; if (mode === 'diff') return 'Diff'; if (mode === 'plan') return 'Plan'; return 'Context'; }; const getFileNameFromPath = (path: string | null): string | null => { if (!path) { return null; } const normalized = path.replace(/\\/g, '/').trim(); if (!normalized) { return null; } const segments = normalized.split('/').filter(Boolean); if (segments.length === 0) { return normalized; } return segments[segments.length - 1] || null; }; const getTabLabel = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => { if (tab.label) { return tab.label; } if (tab.mode === 'file') { return getFileNameFromPath(tab.targetPath) || 'Files'; } return getModeLabel(tab.mode); }; const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => { if (tab.mode === 'file') { return tab.targetPath ? : undefined; } if (tab.mode === 'diff') { return ; } if (tab.mode === 'plan') { return ; } if (tab.mode === 'context') { return ; } if (tab.mode === 'chat') { return ; } return undefined; }; const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => { if (!dedupeKey || !dedupeKey.startsWith('session:')) { return null; } const sessionID = dedupeKey.slice('session:'.length).trim(); return sessionID || null; }; const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => { if (typeof window === 'undefined') { return ''; } const url = new URL(window.location.pathname, window.location.origin); url.searchParams.set('ocPanel', 'session-chat'); url.searchParams.set('sessionId', sessionID); if (directory && directory.trim().length > 0) { url.searchParams.set('directory', directory); } else { url.searchParams.delete('directory'); } url.hash = ''; return url.toString(); }; const truncateTabLabel = (value: string, maxChars: number): string => { if (value.length <= maxChars) { return value; } return `${value.slice(0, maxChars - 3)}...`; }; export const ContextPanel: React.FC = () => { const effectiveDirectory = useEffectiveDirectory() ?? ''; const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]); const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab); const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded); const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth); const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab); const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem(); const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]); const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null; const isOpen = Boolean(panelState?.isOpen && activeTab); const isExpanded = Boolean(isOpen && panelState?.expanded); const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(width); const resizingWidthRef = React.useRef(null); const activeResizePointerIDRef = React.useRef(null); const panelRef = React.useRef(null); const chatFrameRefs = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); React.useEffect(() => { if (!isOpen || wasOpenRef.current) { wasOpenRef.current = isOpen; return; } const frame = window.requestAnimationFrame(() => { panelRef.current?.focus({ preventScroll: true }); }); wasOpenRef.current = true; return () => window.cancelAnimationFrame(frame); }, [isOpen]); const applyLiveWidth = React.useCallback((nextWidth: number) => { const panel = panelRef.current; if (!panel) { return; } panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`); }, []); const handleResizeStart = React.useCallback((event: React.PointerEvent) => { if (!isOpen || isExpanded || !directoryKey) { return; } try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // ignore; fallback listeners still handle drag } activeResizePointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; resizingWidthRef.current = width; applyLiveWidth(width); event.preventDefault(); }, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]); const handleResizeMove = React.useCallback((event: React.PointerEvent) => { if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) { return; } const delta = startXRef.current - event.clientX; const nextWidth = clampWidth(startWidthRef.current + delta); if (resizingWidthRef.current === nextWidth) { return; } resizingWidthRef.current = nextWidth; applyLiveWidth(nextWidth); }, [applyLiveWidth, isResizing]); const handleResizeEnd = React.useCallback((event: React.PointerEvent) => { if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) { return; } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } const finalWidth = resizingWidthRef.current ?? width; setIsResizing(false); activeResizePointerIDRef.current = null; resizingWidthRef.current = null; setContextPanelWidth(directoryKey, finalWidth); }, [directoryKey, setContextPanelWidth, width]); React.useEffect(() => { if (!isResizing) { resizingWidthRef.current = null; } }, [isResizing]); const handleClose = React.useCallback(() => { if (!directoryKey) { return; } closeContextPanel(directoryKey); }, [closeContextPanel, directoryKey]); const handleToggleExpanded = React.useCallback(() => { if (!directoryKey) { return; } toggleContextPanelExpanded(directoryKey); }, [directoryKey, toggleContextPanelExpanded]); const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent) => { if (event.key !== 'Escape') { return; } event.preventDefault(); event.stopPropagation(); handleClose(); }, [handleClose]); React.useEffect(() => { if (!directoryKey || !activeTab) { return; } if (activeTab.mode === 'file' && activeTab.targetPath) { setSelectedFilePath(directoryKey, activeTab.targetPath); return; } if (activeTab.mode === 'diff' && activeTab.targetPath) { setPendingDiffFile(activeTab.targetPath); } }, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]); const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null; const postThemeSyncToEmbeddedChat = React.useCallback(() => { if (typeof window === 'undefined') { return; } const payload = { themeMode, lightThemeId, darkThemeId, currentTheme, }; for (const frame of chatFrameRefs.current.values()) { const frameWindow = frame.contentWindow; if (!frameWindow) { continue; } const directThemeSync = (frameWindow as unknown as { __openchamberApplyThemeSync?: (themePayload: typeof payload) => void; }).__openchamberApplyThemeSync; if (typeof directThemeSync === 'function') { try { directThemeSync(payload); continue; } catch { // fallback to postMessage below } } frameWindow.postMessage( { type: 'openchamber:theme-sync', payload, }, window.location.origin, ); } }, [currentTheme, darkThemeId, lightThemeId, themeMode]); const postEmbeddedVisibilityToChats = React.useCallback(() => { if (typeof window === 'undefined') { return; } for (const [tabID, frame] of chatFrameRefs.current.entries()) { const frameWindow = frame.contentWindow; if (!frameWindow) { continue; } const payload = { visible: activeChatTabID === tabID }; const directVisibilitySync = (frameWindow as unknown as { __openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void; }).__openchamberSetEmbeddedVisibility; if (typeof directVisibilitySync === 'function') { try { directVisibilitySync(payload); continue; } catch { // fallback to postMessage below } } frameWindow.postMessage( { type: 'openchamber:embedded-visibility', payload, }, window.location.origin, ); } }, [activeChatTabID]); React.useLayoutEffect(() => { const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); if (!hasAnyChatTab) { return; } postThemeSyncToEmbeddedChat(); postEmbeddedVisibilityToChats(); }, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); const tabItems = React.useMemo(() => tabs.map((tab) => { const rawLabel = getTabLabel(tab); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); return { id: tab.id, label, icon: getTabIcon(tab), title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: `Close ${label} tab`, }; }), [effectiveDirectory, tabs]); const activeNonChatContent = activeTab?.mode === 'diff' ? : activeTab?.mode === 'context' ? : activeTab?.mode === 'plan' ? : null; const chatTabs = React.useMemo( () => tabs.filter((tab) => tab.mode === 'chat'), [tabs], ); const hasFileTabs = React.useMemo( () => tabs.some((tab) => tab.mode === 'file'), [tabs], ); const isFileTabActive = activeTab?.mode === 'file'; const header = (
{ if (!directoryKey) { return; } setActiveContextPanelTab(directoryKey, tabID); }} onClose={(tabID) => { if (!directoryKey) { return; } closeContextPanelTab(directoryKey, tabID); }} onReorder={(activeTabID, overTabID) => { if (!directoryKey) { return; } reorderContextPanelTabs(directoryKey, activeTabID, overTabID); }} layoutMode="scrollable" variant="default" />
); if (!isOpen) { return null; } const panelStyle: React.CSSProperties = isExpanded ? { ['--oc-context-panel-width' as string]: '100%', width: '100%', minWidth: '100%', maxWidth: '100%', } : { width: 'var(--oc-context-panel-width)', minWidth: 'var(--oc-context-panel-width)', maxWidth: 'var(--oc-context-panel-width)', ['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`, }; return (