import React from 'react'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Button } from '@/components/ui/button'; import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView // or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks // into the eager startup graph even when no such tab is open. const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView }))); const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView }))); const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView }))); const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView }))); const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView }))); import { ProjectContextPanel } from './RightSidebarTabs'; import { SidebarFilesTree } from './SidebarFilesTree'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useBrowserFaviconStore } from '@/stores/useBrowserFaviconStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore'; import { markSessionViewed } from '@/sync/notification-store'; import { setExternallyViewedSession, useDirectoryStore } from '@/sync/sync-context'; import { ContextPanelContent } from './ContextSidebarTab'; import { BrowserPane } from '@/components/browser/BrowserPane'; import { browserUrlLabel } from '@/lib/browser/url'; import { registerBrowserOpener } from '@/lib/browser/controlClient'; import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getActiveRelayDescriptor } from '@/lib/relay/runtime-tunnel'; import { Icon } from "@/components/icon/Icon"; import { EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, EMBEDDED_VISIBILITY_REQUEST, EMBEDDED_VISIBILITY_UPDATE, getActiveEmbeddedSessionChatTab, getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry, type EmbeddedSessionRuntimeBootstrap, } from './contextPanelEmbeddedChat'; import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; const CONTEXT_PANEL_MIN_WIDTH = 380; const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const RESIZE_FOLLOW_INTERVAL_MS = 100; const CONTEXT_TAB_LABEL_MAX_CHARS = 24; type TranslateFn = ReturnType['t']; const EMPTY_SESSION_TITLE_MAP = new Map(); 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 getAvailablePanelWidth = (panel: HTMLElement | null): number | null => { const parentWidth = panel?.parentElement?.clientWidth; if (!parentWidth || parentWidth <= 0) { return null; } return parentWidth; }; 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: ContextPanelMode, t: TranslateFn ): string => { if (mode === 'chat') return t('contextPanel.mode.chat'); if (mode === 'file') return t('contextPanel.mode.files'); if (mode === 'diff') return t('contextPanel.mode.diff'); if (mode === 'walkthrough') return t('contextPanel.mode.walkthrough'); if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'browser') return t('contextPanel.mode.browser'); if (mode === 'git') return t('layout.rightSidebar.git'); if (mode === 'pr') return t('contextPanel.mode.pr'); if (mode === 'notes') return t('contextRail.surface.notes'); if (mode === 'terminal') return t('layout.mainTab.terminal'); return t('contextPanel.mode.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: ContextPanelMode; label: string | null; targetPath: string | null; dedupeKey?: string; sessionTitleFallback?: string | null; stagedDiff?: boolean }, sessionTitleById: ReadonlyMap, t: TranslateFn ): string => { if (tab.mode === 'chat') { const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); if (sessionID) { const sessionTitle = sessionTitleById.get(sessionID)?.trim(); if (sessionTitle) { return sessionTitle; } } const sessionTitleFallback = tab.sessionTitleFallback?.trim(); if (sessionTitleFallback) { return sessionTitleFallback; } return t('contextPanel.mode.chat'); } // Ahead of the stored label on purpose: a browser tab is named after the page // it is showing, and the stored label is only ever the address it opened at. // Keeping that would leave the tab claiming one host while the address bar // shows another. if (tab.mode === 'browser') { return browserUrlLabel(tab.targetPath ?? '') || tab.label || t('contextPanel.mode.browser'); } if (tab.label) { return tab.label; } if (tab.mode === 'file') { return getFileNameFromPath(tab.targetPath) || t('contextPanel.mode.files'); } if (tab.mode === 'diff') { return t('contextPanel.mode.diff'); } return getModeLabel(tab.mode, t); }; const getTabIcon = ( tab: { mode: ContextPanelMode; targetPath: string | null }, faviconByOrigin: Record = {}, ): React.ReactNode | undefined => { if (tab.mode === 'file') { return tab.targetPath ? : undefined; } if (tab.mode === 'diff') { return ; } if (tab.mode === 'walkthrough') { return ; } if (tab.mode === 'git') { return ; } if (tab.mode === 'pr') { return ; } if (tab.mode === 'notes') { return ; } if (tab.mode === 'terminal') { return ; } if (tab.mode === 'plan') { return ; } if (tab.mode === 'context') { return ; } if (tab.mode === 'chat') { return ; } if (tab.mode === 'browser') { const icon = browserFaviconFor(tab.targetPath ?? '', faviconByOrigin); // The page's own icon when it has reported one; the placeholder otherwise, // including in runtimes where a page never can. return icon ? : ; } return undefined; }; const browserFaviconFor = (url: string, faviconByOrigin: Record): string => { try { return faviconByOrigin[new URL(url).origin] ?? ''; } catch { return ''; } }; const EDITOR_TREE_MIN_WIDTH = 200; const EDITOR_TREE_MAX_WIDTH = 480; // The editor surface's file-tree column: docked on the right, resizable from // its left edge, and animated open/closed like the app sidebars. const EditorTreeColumn: React.FC<{ visible: boolean }> = ({ visible }) => { const { t } = useI18n(); const width = useUIStore((state) => state.contextEditorTreeWidth); const setWidth = useUIStore((state) => state.setContextEditorTreeWidth); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); const startWidthRef = React.useRef(width); const liveWidthRef = React.useRef(null); const pointerIDRef = React.useRef(null); const columnRef = React.useRef(null); const clampTreeWidth = React.useCallback((value: number) => { return Math.min(EDITOR_TREE_MAX_WIDTH, Math.max(EDITOR_TREE_MIN_WIDTH, Math.round(value))); }, []); const applyLiveTreeWidth = React.useCallback((nextWidth: number) => { const column = columnRef.current; if (!column) { return; } column.style.width = `${nextWidth}px`; column.style.setProperty('--oc-editor-tree-width', `${nextWidth}px`); }, []); const handlePointerDown = (event: React.PointerEvent) => { if (!visible) { return; } try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // ignore } pointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; liveWidthRef.current = width; event.preventDefault(); }; const handlePointerMove = (event: React.PointerEvent) => { if (!isResizing || pointerIDRef.current !== event.pointerId) { return; } const delta = startXRef.current - event.clientX; const nextWidth = clampTreeWidth(startWidthRef.current + delta); if (liveWidthRef.current === nextWidth) { return; } liveWidthRef.current = nextWidth; applyLiveTreeWidth(nextWidth); }; const handlePointerEnd = (event: React.PointerEvent) => { if (pointerIDRef.current !== event.pointerId) { return; } try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } const finalWidth = clampTreeWidth(liveWidthRef.current ?? width); pointerIDRef.current = null; liveWidthRef.current = null; setIsResizing(false); setWidth(finalWidth); }; const appliedWidth = visible ? width : 0; return (
{visible && (
)}
); }; 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 areTitleMapsEqual = (a: ReadonlyMap, b: ReadonlyMap): boolean => { if (a.size !== b.size) return false; for (const [key, value] of a) { if (b.get(key) !== value) return false; } return true; }; const buildSessionTitleMap = (sessions: Array<{ id: string; title?: string | null }>, sessionIDs: readonly string[]): Map => { if (sessionIDs.length === 0) return EMPTY_SESSION_TITLE_MAP; const wanted = new Set(sessionIDs); const next = new Map(); for (const session of sessions) { if (!wanted.has(session.id)) continue; const title = session.title?.trim(); if (title) next.set(session.id, title); } return next.size === 0 ? EMPTY_SESSION_TITLE_MAP : next; }; const useSessionTitleMap = (directory: string | undefined, sessionIDs: readonly string[]): ReadonlyMap => { const store = useDirectoryStore(directory); const snapshotRef = React.useRef>(EMPTY_SESSION_TITLE_MAP); const sessionIDsRef = React.useRef(sessionIDs); sessionIDsRef.current = sessionIDs; return React.useSyncExternalStore( store.subscribe, React.useCallback(() => { const next = buildSessionTitleMap(store.getState().session, sessionIDsRef.current); if (areTitleMapsEqual(snapshotRef.current, next)) { return snapshotRef.current; } snapshotRef.current = next; return next; }, [store]), () => EMPTY_SESSION_TITLE_MAP, ); }; 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 { t } = useI18n(); 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 openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded); const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth); const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab); const openContextBrowser = useUIStore((state) => state.openContextBrowser); // Lets an agent's browser.open create the tab it needs when none is open yet. // Registered from the panel because opening a tab is panel state, not // something the browser view itself can do before it exists. Reveal the // panel so Electron gives the webview a composited surface; capturePage() // cannot capture the zero-width webview inside a closed panel. React.useEffect(() => { if (!effectiveDirectory) return; return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url)); }, [effectiveDirectory, openContextBrowser]); const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); const contextEditorTreeVisible = useUIStore((state) => state.contextEditorTreeVisible); const toggleContextEditorTree = useUIStore((state) => state.toggleContextEditorTree); const openNewContextBrowserTab = useUIStore((state) => state.openNewContextBrowserTab); const faviconByOrigin = useBrowserFaviconStore((state) => state.byOrigin); const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions); const { themeMode, setThemeMode, 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 [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState(null); const activeModeForWidth = activeTab?.mode ?? null; const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined; const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5; const widthFallbackBase = availablePanelAreaWidth ?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2); const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase)); const chatSessionIDs = React.useMemo(() => { const ids: string[] = []; for (const tab of tabs) { if (tab.mode !== 'chat') continue; const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey); if (sessionID && !ids.includes(sessionID)) ids.push(sessionID); } return ids; }, [tabs]); const sessionTitleById = useSessionTitleMap(directoryKey || undefined, chatSessionIDs); 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 chatFrameSrcByTabIDRef = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); // Tracks the panel area width so fraction-based surface defaults stay // proportional as the window resizes; manual widths remain fixed px. React.useLayoutEffect(() => { const parent = panelRef.current?.parentElement; if (!parent || typeof ResizeObserver === 'undefined') { return; } const observer = new ResizeObserver(() => { setAvailablePanelAreaWidth(parent.clientWidth || null); }); observer.observe(parent); setAvailablePanelAreaWidth(parent.clientWidth || null); return () => observer.disconnect(); }, []); 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]); // Deferred resize: reflowing the chat column and the active surface (xterm, // editor, embedded chat iframes) on every drag frame is unavoidably janky, // so during the drag only a ghost guide line follows the pointer and the // real width is applied once on release (riding the width transition). const resizeAvailableWidthRef = React.useRef(null); // The panel content follows the guide line lazily: the real width is // re-applied at most every RESIZE_FOLLOW_INTERVAL_MS and the standing // 200ms width transition smooths each step, VS Code-style. const resizeFollowTimerRef = React.useRef | null>(null); const applyFollowWidth = React.useCallback(() => { resizeFollowTimerRef.current = null; const panel = panelRef.current; const next = resizingWidthRef.current; if (!panel || next === null) { return; } panel.style.setProperty('--oc-context-panel-width', `${next}px`); }, []); React.useEffect(() => () => { if (resizeFollowTimerRef.current !== null) { clearTimeout(resizeFollowTimerRef.current); } }, []); const clampWidthForDrag = React.useCallback((nextWidth: number) => { const clamped = clampWidth(nextWidth); const available = resizeAvailableWidthRef.current; return available === null ? clamped : Math.min(clamped, Math.max(1, available)); }, []); const handleResizeStart = React.useCallback((event: React.PointerEvent) => { if (!isOpen || isExpanded || !directoryKey) { return; } activeResizePointerIDRef.current = event.pointerId; setIsResizing(true); startXRef.current = event.clientX; startWidthRef.current = width; resizingWidthRef.current = width; // Measure once per drag; no layout reads happen during pointermove. resizeAvailableWidthRef.current = getAvailablePanelWidth(panelRef.current); document.documentElement.style.cursor = 'col-resize'; event.preventDefault(); }, [directoryKey, isExpanded, isOpen, width]); const finishResize = React.useCallback(() => { // Apply the final width once, letting the regular 200ms width transition // carry the panel to the release position. const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width); resizingWidthRef.current = null; resizeAvailableWidthRef.current = null; if (resizeFollowTimerRef.current !== null) { clearTimeout(resizeFollowTimerRef.current); resizeFollowTimerRef.current = null; } document.documentElement.style.cursor = ''; if (directoryKey && activeModeForWidth) { setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth); } setIsResizing(false); activeResizePointerIDRef.current = null; }, [activeModeForWidth, clampWidthForDrag, directoryKey, setContextPanelWidth, width]); // Window-level drag listeners: tracking the pointer via the 3px handle and // pointer capture is unreliable (capture can fail over iframes and a missed // pointerup leaves the drag stuck), so while resizing the whole window // tracks the pointer and any release/cancel/blur ends the drag. React.useEffect(() => { if (!isResizing) { return; } const handleMove = (event: PointerEvent) => { if (activeResizePointerIDRef.current !== event.pointerId) { return; } const delta = startXRef.current - event.clientX; const nextWidth = clampWidthForDrag(startWidthRef.current + delta); if (resizingWidthRef.current === nextWidth) { return; } resizingWidthRef.current = nextWidth; if (resizeFollowTimerRef.current === null) { resizeFollowTimerRef.current = setTimeout(applyFollowWidth, RESIZE_FOLLOW_INTERVAL_MS); } }; const handleUp = (event: PointerEvent) => { if (activeResizePointerIDRef.current !== event.pointerId) { return; } finishResize(); }; const handleWindowBlur = () => { finishResize(); }; window.addEventListener('pointermove', handleMove); window.addEventListener('pointerup', handleUp); window.addEventListener('pointercancel', handleUp); window.addEventListener('blur', handleWindowBlur); return () => { window.removeEventListener('pointermove', handleMove); window.removeEventListener('pointerup', handleUp); window.removeEventListener('pointercancel', handleUp); window.removeEventListener('blur', handleWindowBlur); }; }, [applyFollowWidth, clampWidthForDrag, finishResize, isResizing]); React.useEffect(() => { if (!isResizing) { resizingWidthRef.current = null; document.documentElement.style.cursor = ''; } }, [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; } // Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode). // ghostty-web listens in the bubble phase; stopping capture here would // swallow the key before the terminal ever sees it (issue #2644). if (isTerminalEventTarget(event.target)) { return; } event.preventDefault(); event.stopPropagation(); handleClose(); }, [handleClose]); React.useEffect(() => { if (!directoryKey || !activeTab) { return; } if (activeTab.mode === 'file' && activeTab.targetPath) { setSelectedFilePath(directoryKey, activeTab.targetPath, { allowOutsideRoot: true }); return; } }, [activeTab, directoryKey, setSelectedFilePath]); const chatTabs = React.useMemo( () => tabs.filter((tab) => tab.mode === 'chat'), [tabs], ); const activeChatTabID = isOpen && activeTab?.mode === 'chat' ? activeTab.id : null; const activeChatSessionID = isOpen && activeTab?.mode === 'chat' ? getSessionIDFromDedupeKey(activeTab.dedupeKey) : null; const activeChatTab = getActiveEmbeddedSessionChatTab(chatTabs, activeChatTabID); React.useEffect(() => { if (!isOpen || !directoryKey || !activeChatSessionID || typeof window === 'undefined') { return; } const markActiveChatViewed = () => { if (document.visibilityState === 'hidden' || !document.hasFocus()) { setExternallyViewedSession(directoryKey, activeChatSessionID, false); return; } markSessionViewed(activeChatSessionID); setExternallyViewedSession(directoryKey, activeChatSessionID, true); }; markActiveChatViewed(); const interval = window.setInterval(markActiveChatViewed, 10_000); window.addEventListener('focus', markActiveChatViewed); window.addEventListener('blur', markActiveChatViewed); document.addEventListener('visibilitychange', markActiveChatViewed); return () => { window.clearInterval(interval); window.removeEventListener('focus', markActiveChatViewed); window.removeEventListener('blur', markActiveChatViewed); document.removeEventListener('visibilitychange', markActiveChatViewed); setExternallyViewedSession(directoryKey, activeChatSessionID, false); }; }, [activeChatSessionID, directoryKey, isOpen]); const getEmbeddedChatSrc = React.useCallback((tabID: string, sessionID: string, readOnly: boolean): string => { return getOrCreateEmbeddedSessionChatURL(chatFrameSrcByTabIDRef.current, tabID, sessionID, directoryKey || null, readOnly, { mode: themeMode, lightThemeId, darkThemeId, currentTheme, }, { allowPromptingSubagentSessions }); }, [allowPromptingSubagentSessions, currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]); const activeChatSrc = activeChatTab && activeChatSessionID ? getEmbeddedChatSrc(activeChatTab.id, activeChatSessionID, activeChatTab.readOnly) : null; React.useEffect(() => { const liveTabIDs = new Set(tabs.map((tab) => tab.id)); for (const tabID of chatFrameSrcByTabIDRef.current.keys()) { if (!liveTabIDs.has(tabID)) { chatFrameSrcByTabIDRef.current.delete(tabID); } } }, [tabs]); const handleDiffScopeChange = React.useCallback((nextScope: PendingDiffScope) => { if (!directoryKey || activeTab?.mode !== 'diff') { return; } openContextPanelTab(directoryKey, { mode: 'diff', targetPath: activeTab.targetPath, stagedDiff: nextScope === 'staged', diffScope: nextScope, }); }, [activeTab, directoryKey, openContextPanelTab]); 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; } frameWindow.postMessage( { type: 'openchamber:theme-sync', payload, }, window.location.origin, ); } }, [currentTheme, darkThemeId, lightThemeId, themeMode]); const postChatSettingsSyncToEmbeddedChat = React.useCallback(() => { if (typeof window === 'undefined') return; const payload = { allowPromptingSubagentSessions }; for (const frame of chatFrameRefs.current.values()) { const frameWindow = frame.contentWindow; if (!frameWindow) continue; frameWindow.postMessage({ type: 'openchamber:chat-settings-sync', payload }, window.location.origin); } }, [allowPromptingSubagentSessions]); const postEmbeddedVisibilityToChat = React.useCallback(( tabID: string, frame: HTMLIFrameElement, targetOrigin: string, ) => { const frameWindow = frame.contentWindow; if (!frameWindow) { return; } frameWindow.postMessage( { type: EMBEDDED_VISIBILITY_UPDATE, payload: { visible: activeChatTabID === tabID }, }, targetOrigin, ); }, [activeChatTabID]); const postEmbeddedVisibilityToChats = React.useCallback(() => { if (typeof window === 'undefined') { return; } for (const [tabID, frame] of chatFrameRefs.current.entries()) { postEmbeddedVisibilityToChat(tabID, frame, window.location.origin); } }, [postEmbeddedVisibilityToChat]); React.useEffect(() => { if (typeof window === 'undefined') { return; } const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) { return; } const sourceChatFrame = Array.from(chatFrameRefs.current.entries()) .find(([, frame]) => frame.contentWindow === event.source); if (!sourceChatFrame) { return; } const data = event.data as { type?: unknown; requestId?: unknown }; if (data?.type === EMBEDDED_VISIBILITY_REQUEST) { const [tabID, frame] = sourceChatFrame; postEmbeddedVisibilityToChat(tabID, frame, event.origin); return; } if (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) { if (typeof data.requestId !== 'string' || !data.requestId) return; const runtimeKey = getRuntimeKey(); const payload: EmbeddedSessionRuntimeBootstrap = { apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), localOrigin: typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '', runtimeHeaders: getRuntimeExtraHeadersSync(), relayHostId: runtimeKey.startsWith('host:') ? runtimeKey.slice('host:'.length) : '', relay: getActiveRelayDescriptor() ?? undefined, }; (event.source as WindowProxy | null)?.postMessage({ type: EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, requestId: data.requestId, payload, }, event.origin); return; } if (data?.type === 'openchamber:theme-sync-request') { postThemeSyncToEmbeddedChat(); return; } if (data?.type === 'openchamber:chat-settings-request') { postChatSettingsSyncToEmbeddedChat(); return; } if (data?.type !== 'openchamber:cycle-theme-request') { return; } const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; const currentIndex = modes.indexOf(themeMode); const nextIndex = (currentIndex + 1) % modes.length; setThemeMode(modes[nextIndex]); }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]); React.useLayoutEffect(() => { const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); if (!hasAnyChatTab) { return; } postThemeSyncToEmbeddedChat(); postChatSettingsSyncToEmbeddedChat(); postEmbeddedVisibilityToChats(); }, [darkThemeId, lightThemeId, postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]); // The rail switches between surfaces (modes); the in-panel strip only lists // instances of the active multi-instance surface (open files, split chats, // browser targets). const isMultiInstanceMode = activeTab?.mode === 'file' || activeTab?.mode === 'chat' || activeTab?.mode === 'browser'; const activeModeTabs = React.useMemo( () => (activeTab ? tabs.filter((tab) => tab.mode === activeTab.mode) : []), [activeTab, tabs], ); const tabItems = React.useMemo(() => activeModeTabs.map((tab) => { const rawLabel = getTabLabel(tab, sessionTitleById, t); const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS); const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory); return { id: tab.id, label, icon: getTabIcon(tab, faviconByOrigin), title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel, closeLabel: t('contextPanel.tab.closeTabAria', { label }), }; }), [activeModeTabs, effectiveDirectory, faviconByOrigin, sessionTitleById, t]); const activeNonChatContent = activeTab?.mode === 'context' ? : activeTab?.mode === 'git' ? : activeTab?.mode === 'pr' ? : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' ? : null; const browserTabs = React.useMemo( () => tabs.filter((tab) => tab.mode === 'browser'), [tabs], ); const diffTabs = React.useMemo( () => tabs.filter((tab) => tab.mode === 'diff'), [tabs], ); const hasTerminalTab = React.useMemo( () => tabs.some((tab) => tab.mode === 'terminal'), [tabs], ); // Keep-alive: the walkthrough holds reading progress and scroll position that // a remount would silently throw away. const hasWalkthroughTab = React.useMemo( () => tabs.some((tab) => tab.mode === 'walkthrough'), [tabs], ); const hasFileTabs = React.useMemo( () => tabs.some((tab) => tab.mode === 'file'), [tabs], ); const hasOpenEditorFile = React.useMemo( () => tabs.some((tab) => tab.mode === 'file' && tab.targetPath), [tabs], ); const isFileTabActive = activeTab?.mode === 'file'; const closeContextPanelTabs = useUIStore((state) => state.closeContextPanelTabs); const renderTabContextMenu = React.useCallback( (args: { id: string; index: number; allIds: string[]; close: () => void }): React.ReactNode => { if (!directoryKey) { return null; } const { id, index, allIds, close } = args; const closeOthers = () => closeContextPanelTabs(directoryKey, allIds.filter((tabId) => tabId !== id)); const closeToLeft = () => closeContextPanelTabs(directoryKey, allIds.slice(0, index)); const closeToRight = () => closeContextPanelTabs(directoryKey, allIds.slice(index + 1)); const closeAll = () => closeContextPanelTabs(directoryKey, allIds); const hasOthers = allIds.length > 1; const isFirst = index === 0; const isLast = index === allIds.length - 1; return ( <> {t('contextPanel.tab.menu.close')} {t('contextPanel.tab.menu.closeOthers')} {t('contextPanel.tab.menu.closeToLeft')} {t('contextPanel.tab.menu.closeToRight')} {t('contextPanel.tab.menu.closeAll')} ); }, [closeContextPanelTabs, directoryKey, t], ); const header = (
{isMultiInstanceMode ? ( { 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" tabContextMenu={renderTabContextMenu} /> ) : (
{activeTab ? getTabIcon(activeTab, faviconByOrigin) : null} {activeTab ? getModeLabel(activeTab.mode, t) : null}
)}
{activeTab?.mode === 'browser' ? ( ) : null} {isFileTabActive ? ( ) : null}
); // width/min/max stay interpolable across open/close (no instant min/max // jumps) so the 200ms width transition matches the sidebars. const panelStyle: React.CSSProperties = !isOpen ? { ['--oc-context-panel-width' as string]: `${width}px`, width: 0, maxWidth: '100%', overflowX: 'clip', } : isExpanded ? { // px, not '100%': px↔% width changes do not interpolate, which // would make the expand/collapse width snap instead of animating. ['--oc-context-panel-width' as string]: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%', width: availablePanelAreaWidth !== null ? `${availablePanelAreaWidth}px` : '100%', maxWidth: '100%', } : { width: 'min(var(--oc-context-panel-width), 100%)', maxWidth: '100%', overflowX: 'clip', ['--oc-context-panel-width' as string]: `${width}px`, }; return (