Files
openchamber/packages/ui/src/components/layout/Sidebar.tsx
T
Bohdan Triapitsyn 8587db89b1 perf: reduce UI render fanout and scroll jitter
- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke.

- Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity.

- Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders.

- Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
2026-04-04 02:19:55 +03:00

169 lines
5.9 KiB
TypeScript

import React from 'react';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useUIStore } from '@/stores/useUIStore';
import { isDesktopShell } from '@/lib/desktop';
export const SIDEBAR_CONTENT_WIDTH = 250;
const SIDEBAR_MIN_WIDTH = 250;
const SIDEBAR_MAX_WIDTH = 500;
interface SidebarProps {
isOpen: boolean;
isMobile: boolean;
children: React.ReactNode;
className?: string;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const [isResizing, setIsResizing] = React.useState(false);
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
const resizingWidthRef = React.useRef<number | null>(null);
const activeResizePointerIDRef = React.useRef<number | null>(null);
const sidebarRef = React.useRef<HTMLElement | null>(null);
const clampSidebarWidth = React.useCallback((value: number) => {
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, value));
}, []);
const applyLiveWidth = React.useCallback((nextWidth: number) => {
const sidebar = sidebarRef.current;
if (!sidebar) {
return;
}
sidebar.style.setProperty('--oc-left-sidebar-width', `${nextWidth}px`);
}, []);
React.useEffect(() => {
if (isMobile && isResizing) {
setIsResizing(false);
}
}, [isMobile, isResizing]);
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
activeResizePointerIDRef.current = null;
}
}, [isResizing]);
if (isMobile) {
return null;
}
const appliedWidth = isOpen ? Math.min(
SIDEBAR_MAX_WIDTH,
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
) : 0;
const handlePointerDown = (event: React.PointerEvent) => {
if (!isOpen) {
return;
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// ignore
}
activeResizePointerIDRef.current = event.pointerId;
setIsResizing(true);
startXRef.current = event.clientX;
startWidthRef.current = appliedWidth;
resizingWidthRef.current = appliedWidth;
applyLiveWidth(appliedWidth);
event.preventDefault();
};
const handlePointerMove = (event: React.PointerEvent) => {
if (isMobile || !isResizing || activeResizePointerIDRef.current !== event.pointerId) {
return;
}
const delta = event.clientX - startXRef.current;
const nextWidth = clampSidebarWidth(startWidthRef.current + delta);
if (resizingWidthRef.current === nextWidth) {
return;
}
resizingWidthRef.current = nextWidth;
applyLiveWidth(nextWidth);
};
const handlePointerEnd = (event: React.PointerEvent) => {
if (activeResizePointerIDRef.current !== event.pointerId || isMobile) {
return;
}
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore
}
const finalWidth = clampSidebarWidth(resizingWidthRef.current ?? appliedWidth);
activeResizePointerIDRef.current = null;
resizingWidthRef.current = null;
setIsResizing(false);
setSidebarWidth(finalWidth);
};
return (
<aside
ref={sidebarRef}
className={cn(
'relative flex h-full overflow-hidden border-r border-border/40',
isDesktopApp
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
: 'bg-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
!isOpen && 'border-r-0',
className,
)}
style={{
width: 'var(--oc-left-sidebar-width)',
minWidth: 'var(--oc-left-sidebar-width)',
maxWidth: 'var(--oc-left-sidebar-width)',
['--oc-left-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
overflowX: 'clip',
}}
aria-hidden={!isOpen || appliedWidth === 0}
>
{isOpen && (
<div
className={cn(
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
isResizing && 'bg-[var(--interactive-border)]'
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
role="separator"
aria-orientation="vertical"
aria-label="Resize left panel"
/>
)}
<div
className={cn(
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
aria-hidden={!isOpen}
>
<div className="flex-1 overflow-hidden">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
</aside>
);
};