import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from 'react'; import type { BonsaiShellProps } from '../../lib/contracts'; import { normalizeThemeMode, resolveThemeMode, THEME_STORAGE_KEY, type ThemeMode } from '../theme'; import { ChatSurface } from './ChatSurface'; import { ConversationList } from './ConversationList'; import { Header, type ChatWidth } from './Header'; import { InspectorPanel } from './InspectorPanel'; import { ModelRail } from './ModelRail'; import { SettingsSheet } from './SettingsSheet'; const BROWSER_THEME_COLORS = { light: '#f3f6f2', dark: '#111511', } as const; const SIDEBAR_LAYOUT_STORAGE_KEY = 'bonsai-sidebar-layout-v1'; export const SIDEBAR_COLLAPSE_THRESHOLD = 132; export const SIDEBAR_EXPAND_THRESHOLD = 176; const MIN_CHAT_WIDTH = 360; const RESIZE_STEP = 16; type SidebarSide = 'left' | 'right'; interface SidebarLayout { leftWidth: number; rightWidth: number; leftOpen: boolean; rightOpen: boolean; } interface SidebarDrag { side: SidebarSide; pointerId: number; startX: number; startWidth: number; moved: boolean; } export function resolveSidebarDrag(rawWidth: number, open: boolean, maxWidth: number) { if ((open && rawWidth <= SIDEBAR_COLLAPSE_THRESHOLD) || (!open && rawWidth < SIDEBAR_EXPAND_THRESHOLD)) { return { open: false, width: 0 } as const; } return { open: true, width: Math.round(Math.min(maxWidth, Math.max(SIDEBAR_COLLAPSE_THRESHOLD + 1, rawWidth))), } as const; } function defaultSidebarLayout(): SidebarLayout { const large = (globalThis.innerWidth ?? 0) >= 2400; return { leftWidth: large ? 340 : 260, rightWidth: large ? 420 : 340, leftOpen: true, rightOpen: true, }; } function readSidebarLayout(): SidebarLayout { const fallback = defaultSidebarLayout(); try { const raw = globalThis.localStorage?.getItem(SIDEBAR_LAYOUT_STORAGE_KEY); if (!raw) return fallback; const stored = JSON.parse(raw) as Partial; const normalizeWidth = (value: unknown, defaultWidth: number) => ( typeof value === 'number' && Number.isFinite(value) ? Math.min(800, Math.max(SIDEBAR_EXPAND_THRESHOLD, Math.round(value))) : defaultWidth ); return { leftWidth: normalizeWidth(stored.leftWidth, fallback.leftWidth), rightWidth: normalizeWidth(stored.rightWidth, fallback.rightWidth), leftOpen: typeof stored.leftOpen === 'boolean' ? stored.leftOpen : fallback.leftOpen, rightOpen: typeof stored.rightOpen === 'boolean' ? stored.rightOpen : fallback.rightOpen, }; } catch { return fallback; } } export function BonsaiShell(props: BonsaiShellProps) { const [initialSidebarLayout] = useState(readSidebarLayout); const [leftSidebarOpen, setLeftSidebarOpen] = useState(initialSidebarLayout.leftOpen); const [rightSidebarOpen, setRightSidebarOpen] = useState(initialSidebarLayout.rightOpen); const [leftSidebarWidth, setLeftSidebarWidth] = useState(initialSidebarLayout.leftWidth); const [rightSidebarWidth, setRightSidebarWidth] = useState(initialSidebarLayout.rightWidth); const [resizingSide, setResizingSide] = useState(null); const [chatWidth, setChatWidth] = useState('centered'); const [themeMode, setThemeMode] = useState(() => { try { return normalizeThemeMode(globalThis.localStorage?.getItem(THEME_STORAGE_KEY)); } catch { return 'system'; } }); const [systemDark, setSystemDark] = useState(() => globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false); const workspaceRef = useRef(null); const dragRef = useRef(null); const suppressClickRef = useRef(null); const leftOpenRef = useRef(leftSidebarOpen); const rightOpenRef = useRef(rightSidebarOpen); const leftWidthRef = useRef(leftSidebarWidth); const rightWidthRef = useRef(rightSidebarWidth); const activeModel = props.models.find((model) => model.id === props.activeModelId); const busy = props.streamState === 'loading' || props.streamState === 'streaming'; const resolvedTheme = resolveThemeMode(themeMode, systemDark); const artifacts = useMemo(() => props.messages.flatMap((message) => message.artifacts ?? []), [props.messages]); leftOpenRef.current = leftSidebarOpen; rightOpenRef.current = rightSidebarOpen; leftWidthRef.current = leftSidebarWidth; rightWidthRef.current = rightSidebarWidth; useEffect(() => { const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)'); if (!media) return; const handleChange = (event: MediaQueryListEvent) => setSystemDark(event.matches); setSystemDark(media.matches); media.addEventListener('change', handleChange); return () => media.removeEventListener('change', handleChange); }, []); useEffect(() => { try { globalThis.localStorage?.setItem(THEME_STORAGE_KEY, themeMode); } catch { // Theme still works for the current tab when storage is unavailable. } }, [themeMode]); useEffect(() => { const timer = window.setTimeout(() => { try { globalThis.localStorage?.setItem(SIDEBAR_LAYOUT_STORAGE_KEY, JSON.stringify({ leftWidth: leftSidebarWidth, rightWidth: rightSidebarWidth, leftOpen: leftSidebarOpen, rightOpen: rightSidebarOpen, } satisfies SidebarLayout)); } catch { // Sidebar layout still works for the current tab when storage is unavailable. } }, 140); return () => window.clearTimeout(timer); }, [leftSidebarOpen, leftSidebarWidth, rightSidebarOpen, rightSidebarWidth]); useEffect(() => { const clampToViewport = () => { const workspaceWidth = workspaceRef.current?.clientWidth ?? globalThis.innerWidth ?? 0; if (workspaceWidth <= 820) return; const available = Math.max(SIDEBAR_EXPAND_THRESHOLD * 2, workspaceWidth - MIN_CHAT_WIDTH); const left = leftOpenRef.current ? leftWidthRef.current : 0; const right = rightOpenRef.current ? rightWidthRef.current : 0; if (left + right <= available) return; const scale = available / (left + right); if (leftOpenRef.current) setLeftSidebarWidth(Math.max(SIDEBAR_EXPAND_THRESHOLD, Math.floor(left * scale))); if (rightOpenRef.current) setRightSidebarWidth(Math.max(SIDEBAR_EXPAND_THRESHOLD, Math.floor(right * scale))); }; clampToViewport(); window.addEventListener('resize', clampToViewport); return () => window.removeEventListener('resize', clampToViewport); }, []); useEffect(() => { const themeColor = BROWSER_THEME_COLORS[resolvedTheme]; const meta = document.querySelector('meta[name="theme-color"]'); const previousMetaColor = meta?.content; const previousRootBackground = document.documentElement.style.backgroundColor; const previousBodyBackground = document.body.style.backgroundColor; const previousColorScheme = document.documentElement.style.colorScheme; if (meta) meta.content = themeColor; document.documentElement.style.backgroundColor = themeColor; document.documentElement.style.colorScheme = resolvedTheme; document.body.style.backgroundColor = themeColor; return () => { if (meta && previousMetaColor !== undefined) meta.content = previousMetaColor; document.documentElement.style.backgroundColor = previousRootBackground; document.documentElement.style.colorScheme = previousColorScheme; document.body.style.backgroundColor = previousBodyBackground; }; }, [resolvedTheme]); if (!activeModel) { throw new Error(`Active model ${props.activeModelId} is missing from the model catalog`); } const setSidebarOpen = (side: SidebarSide, open: boolean) => { if (side === 'left') { leftOpenRef.current = open; setLeftSidebarOpen(open); } else { rightOpenRef.current = open; setRightSidebarOpen(open); } }; const toggleSidebar = (side: SidebarSide) => { setSidebarOpen(side, !(side === 'left' ? leftOpenRef.current : rightOpenRef.current)); }; const resizeMaximum = (side: SidebarSide, workspaceWidth: number): number => { const large = workspaceWidth >= 2400; const hardMaximum = side === 'left' ? (large ? 640 : 520) : (large ? 720 : 600); const otherWidth = side === 'left' ? (rightOpenRef.current ? rightWidthRef.current : 0) : (leftOpenRef.current ? leftWidthRef.current : 0); return Math.max( SIDEBAR_EXPAND_THRESHOLD, Math.min(hardMaximum, workspaceWidth - otherWidth - MIN_CHAT_WIDTH), ); }; const beginSidebarResize = (side: SidebarSide, event: PointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); dragRef.current = { side, pointerId: event.pointerId, startX: event.clientX, startWidth: side === 'left' ? leftWidthRef.current : rightWidthRef.current, moved: false, }; setResizingSide(side); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }; const moveSidebarResize = (event: PointerEvent) => { const drag = dragRef.current; const workspace = workspaceRef.current; if (!drag || drag.pointerId !== event.pointerId || !workspace) return; event.preventDefault(); if (Math.abs(event.clientX - drag.startX) > 2) drag.moved = true; const rect = workspace.getBoundingClientRect(); const rawWidth = drag.side === 'left' ? event.clientX - rect.left : rect.right - event.clientX; const currentlyOpen = drag.side === 'left' ? leftOpenRef.current : rightOpenRef.current; const next = resolveSidebarDrag(rawWidth, currentlyOpen, resizeMaximum(drag.side, rect.width)); setSidebarOpen(drag.side, next.open); if (next.open) { if (drag.side === 'left') { leftWidthRef.current = next.width; setLeftSidebarWidth(next.width); } else { rightWidthRef.current = next.width; setRightSidebarWidth(next.width); } } }; const endSidebarResize = (event: PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } const remainsOpen = drag.side === 'left' ? leftOpenRef.current : rightOpenRef.current; if (!remainsOpen) { if (drag.side === 'left') setLeftSidebarWidth(drag.startWidth); else setRightSidebarWidth(drag.startWidth); } if (drag.moved) { suppressClickRef.current = drag.side; window.setTimeout(() => { if (suppressClickRef.current === drag.side) suppressClickRef.current = null; }, 0); } dragRef.current = null; setResizingSide(null); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; const handleResizerClick = (side: SidebarSide) => { if (suppressClickRef.current === side) { suppressClickRef.current = null; return; } toggleSidebar(side); }; const handleResizerKeyDown = (side: SidebarSide, event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleSidebar(side); return; } if (event.key === 'Home') { event.preventDefault(); setSidebarOpen(side, false); return; } if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); const expanding = side === 'left' ? event.key === 'ArrowRight' : event.key === 'ArrowLeft'; const open = side === 'left' ? leftOpenRef.current : rightOpenRef.current; if (!open) { if (expanding) setSidebarOpen(side, true); return; } const currentWidth = side === 'left' ? leftWidthRef.current : rightWidthRef.current; const workspaceWidth = workspaceRef.current?.clientWidth ?? globalThis.innerWidth ?? 0; const nextWidth = Math.min( resizeMaximum(side, workspaceWidth), currentWidth + (expanding ? RESIZE_STEP : -RESIZE_STEP), ); if (nextWidth <= SIDEBAR_COLLAPSE_THRESHOLD) { setSidebarOpen(side, false); } else if (side === 'left') { setLeftSidebarWidth(nextWidth); } else { setRightSidebarWidth(nextWidth); } }; const scrollToArtifact = (artifactId: string) => { const artifact = document.getElementById(`artifact-${artifactId}`); if (!artifact) return; const reducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; artifact.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' }); artifact.focus({ preventScroll: true }); }; const workspaceStyle = { '--model-rail-width': leftSidebarOpen ? `${leftSidebarWidth}px` : '0px', '--inspector-width': rightSidebarOpen ? `${rightSidebarWidth}px` : '0px', } as CSSProperties; return (
toggleSidebar('left')} onToggleRightSidebar={() => toggleSidebar('right')} onToggleChatWidth={() => setChatWidth((width) => width === 'centered' ? 'wide' : 'centered')} />
); } interface SidebarResizerProps { side: SidebarSide; open: boolean; width: number; onPointerDown: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void; onPointerUp: (event: PointerEvent) => void; onPointerCancel: (event: PointerEvent) => void; onClick: () => void; onKeyDown: (event: KeyboardEvent) => void; } function SidebarResizer({ side, open, width, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClick, onKeyDown, }: SidebarResizerProps) { const label = `${open ? 'Resize or collapse' : 'Expand'} ${side === 'left' ? 'model' : 'telemetry'} sidebar`; const arrow = side === 'left' ? (open ? '‹' : '›') : (open ? '›' : '‹'); return (
); }