Files
openchamber/packages/ui/src/components/layout/ContextPanel.tsx
T
Bohdan TriapitsynandIuliia Ivashko 321cc7252a Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary
Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements.

## Key Changes

**Sidebar & Navigation Redesign**
- Redesigned sessions sidebar layout with unified button primitives
- Added activity sections with project grouping and improved session organization
- Refined sidebar corners, spacing, and visual hierarchy
- Removed NavRail component in favor of streamlined sidebar
- Stabilized sessions bar toggle position in fullscreen mode

**Performance Optimizations**
- Reduced chat streaming CPU usage and storage churn
- Optimized task tool polling and live timers with debouncing
- Prevented chat state races and reduced background request load
- Debounced draft writes and coalesced session reloads
- Optimized message store updates and turn tracking

**Theme & Visual System**
- Added theme-aware window corners (desktop) and border radius tokens
- Introduced glassmorphism effects on desktop sidebar
- Added backdrop blur to UI elements

**Chat Experience**
- Added session-based permission auto-accept toggle in chat input
- Polished permission shield UX with improved icon sizing and spacing
- Fixed chat scroll-to-bottom behavior and timeline tracking
- Enhanced tool output display with better path label detection
- Removed duplicate draft context details in chat header
- Added text selection menu to chat messages

**Git Improvements**
- Refreshed git history visual design with cleaner dividers
- Added remote removal action in sync selector
- Stabilized git polling to prevent excessive requests
- Improved tool output rendering for git operations

**Settings & Panels**
- Fixed mobile scrolling on settings pages
- Made outside-click settings close instantly
- Reduced settings load churn and CPU spikes
- Improved services dropdown layout and spacing
- Softened panel resize handles

**Desktop Integration**
- Synced macOS window theme with app theme
- Restored window dragging in sidebar header zones
- Fixed system window corners on macOS
- Improved header session metadata and action controls

**Button & Component Standardization**
- Unified button primitives across all components
- Standardized destructive action patterns
- Removed unused button variants (button-large, button-small)
- Aligned context tab close hit areas

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
2026-03-20 01:01:03 +02:00

579 lines
18 KiB
TypeScript

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
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
: undefined;
}
if (tab.mode === 'diff') {
return <RiArrowLeftRightLine className="h-3.5 w-3.5" />;
}
if (tab.mode === 'plan') {
return <RiFileTextLine className="h-3.5 w-3.5" />;
}
if (tab.mode === 'context') {
return <RiDonutChartFill className="h-3.5 w-3.5" />;
}
if (tab.mode === 'chat') {
return <RiChat4Line className="h-3.5 w-3.5" />;
}
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<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const panelRef = React.useRef<HTMLElement | null>(null);
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(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<HTMLElement>) => {
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'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
: activeTab?.mode === 'context'
? <ContextPanelContent />
: activeTab?.mode === 'plan'
? <PlanView />
: 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 = (
<header className="flex h-10 items-stretch border-b border-transparent">
<SortableTabsStrip
items={tabItems}
activeId={activeTab?.id ?? null}
onSelect={(tabID) => {
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="active-pill"
activePillLowercase={false}
activePillInsetClassName="gap-0.5 pt-0.5 pb-1.5"
/>
<div className="flex items-end gap-1 px-1.5 pb-1.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleToggleExpanded}
className="h-7 w-7 p-0"
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
>
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
className="h-7 w-7 p-0"
title="Close panel"
aria-label="Close panel"
>
<RiCloseLine className="h-3.5 w-3.5" />
</Button>
</div>
</header>
);
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 (
<aside
ref={panelRef}
data-context-panel="true"
tabIndex={-1}
className={cn(
'flex min-h-0 flex-col overflow-hidden bg-background',
!isExpanded && 'border-l border-border/40',
isExpanded
? 'absolute inset-0 z-20 min-w-0'
: 'relative h-full flex-shrink-0',
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
)}
onKeyDownCapture={handlePanelKeyDownCapture}
style={panelStyle}
>
{!isExpanded && (
<div
className={cn(
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize context panel"
/>
)}
{header}
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
{hasFileTabs ? (
<div className={cn('absolute inset-0', isFileTabActive ? 'block' : 'hidden')}>
<FilesView mode="editor-only" />
</div>
) : null}
{chatTabs.map((tab) => {
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
if (!sessionID) {
return null;
}
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null);
if (!src) {
return null;
}
return (
<iframe
key={tab.id}
ref={(node) => {
if (!node) {
chatFrameRefs.current.delete(tab.id);
return;
}
chatFrameRefs.current.set(tab.id, node);
}}
src={src}
title={`Session chat ${sessionID}`}
className={cn(
'absolute inset-0 h-full w-full border-0 bg-background',
activeChatTabID === tab.id ? 'block' : 'hidden'
)}
onLoad={() => {
postThemeSyncToEmbeddedChat();
postEmbeddedVisibilityToChats();
}}
/>
);
})}
{activeTab?.mode !== 'chat' && !isFileTabActive ? activeNonChatContent : null}
</div>
</aside>
);
};