Spaces:
Running
Running
| 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<SidebarLayout>; | |
| 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<SidebarSide | null>(null); | |
| const [chatWidth, setChatWidth] = useState<ChatWidth>('centered'); | |
| const [themeMode, setThemeMode] = useState<ThemeMode>(() => { | |
| 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<HTMLDivElement>(null); | |
| const dragRef = useRef<SidebarDrag | null>(null); | |
| const suppressClickRef = useRef<SidebarSide | null>(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<HTMLMetaElement>('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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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<HTMLDivElement>) => { | |
| 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 ( | |
| <div className="station-frame" data-theme={resolvedTheme} data-theme-mode={themeMode}> | |
| <Header | |
| onOpenSettings={props.onOpenSettings} | |
| disabled={busy} | |
| leftSidebarOpen={leftSidebarOpen} | |
| rightSidebarOpen={rightSidebarOpen} | |
| chatWidth={chatWidth} | |
| onToggleLeftSidebar={() => toggleSidebar('left')} | |
| onToggleRightSidebar={() => toggleSidebar('right')} | |
| onToggleChatWidth={() => setChatWidth((width) => width === 'centered' ? 'wide' : 'centered')} | |
| /> | |
| <div | |
| ref={workspaceRef} | |
| className={`workspace-grid layout-${chatWidth}${leftSidebarOpen ? '' : ' is-models-collapsed'}${rightSidebarOpen ? '' : ' is-inspector-collapsed'}${resizingSide ? ' is-resizing' : ''}`} | |
| style={workspaceStyle} | |
| > | |
| <ModelRail | |
| hidden={!leftSidebarOpen} | |
| models={props.models} | |
| activeModelId={props.activeModelId} | |
| busy={busy} | |
| onModelSelect={props.onModelSelect} | |
| /> | |
| {props.conversationListOpen ? <ConversationList | |
| conversations={props.conversations} | |
| activeConversationId={props.activeConversationId} | |
| onNewConversation={props.onNewConversation} | |
| onOpenConversation={props.onOpenConversation} | |
| onOpenConversationSettings={props.onOpenConversationSettings} | |
| onDeleteConversation={props.onDeleteConversation} | |
| /> : <ChatSurface | |
| activeModel={activeModel} | |
| loadedModelId={props.loadedModelId} | |
| messages={props.messages} | |
| streamState={props.streamState} | |
| modelLoadProgress={props.modelLoadProgress} | |
| toolsEnabled={props.toolsEnabled} | |
| runtimeStatus={props.runtimeStatus} | |
| error={props.error} | |
| shardRetry={props.shardRetry} | |
| theme={resolvedTheme} | |
| onLoadModel={props.onLoadModel} | |
| onDismissError={props.onDismissError} | |
| onRetryShard={props.onRetryShard} | |
| onSend={props.onSend} | |
| onRegenerate={props.onRegenerate} | |
| onEditUserMessage={props.onEditUserMessage} | |
| onCancel={props.onCancel} | |
| onToolsEnabledChange={props.onToolsEnabledChange} | |
| onOpenConversationList={props.onOpenConversationList} | |
| onOpenSettings={props.onOpenSettings} | |
| />} | |
| <InspectorPanel | |
| hidden={!rightSidebarOpen} | |
| telemetry={props.telemetry} | |
| toolEvents={props.toolEvents} | |
| artifacts={artifacts} | |
| onSelectArtifact={scrollToArtifact} | |
| /> | |
| <SidebarResizer | |
| side="left" | |
| open={leftSidebarOpen} | |
| width={leftSidebarWidth} | |
| onPointerDown={(event) => beginSidebarResize('left', event)} | |
| onPointerMove={moveSidebarResize} | |
| onPointerUp={endSidebarResize} | |
| onPointerCancel={endSidebarResize} | |
| onClick={() => handleResizerClick('left')} | |
| onKeyDown={(event) => handleResizerKeyDown('left', event)} | |
| /> | |
| <SidebarResizer | |
| side="right" | |
| open={rightSidebarOpen} | |
| width={rightSidebarWidth} | |
| onPointerDown={(event) => beginSidebarResize('right', event)} | |
| onPointerMove={moveSidebarResize} | |
| onPointerUp={endSidebarResize} | |
| onPointerCancel={endSidebarResize} | |
| onClick={() => handleResizerClick('right')} | |
| onKeyDown={(event) => handleResizerKeyDown('right', event)} | |
| /> | |
| </div> | |
| <SettingsSheet | |
| open={props.settingsOpen} | |
| themeMode={themeMode} | |
| onThemeModeChange={setThemeMode} | |
| activeModel={activeModel} | |
| telemetry={props.telemetry} | |
| settings={props.settings} | |
| onSettingsChange={props.onSettingsChange} | |
| onPersistStorage={props.onPersistStorage} | |
| modelLoaded={props.loadedModelId !== null} | |
| onUnloadModel={props.onUnloadModel} | |
| onClearStorage={props.onClearStorage} | |
| onClearConversation={props.onClearConversation} | |
| onClose={props.onCloseSettings} | |
| /> | |
| </div> | |
| ); | |
| } | |
| interface SidebarResizerProps { | |
| side: SidebarSide; | |
| open: boolean; | |
| width: number; | |
| onPointerDown: (event: PointerEvent<HTMLDivElement>) => void; | |
| onPointerMove: (event: PointerEvent<HTMLDivElement>) => void; | |
| onPointerUp: (event: PointerEvent<HTMLDivElement>) => void; | |
| onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void; | |
| onClick: () => void; | |
| onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => 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 ( | |
| <div | |
| className={`sidebar-resizer sidebar-resizer-${side}${open ? '' : ' is-collapsed'}`} | |
| role="separator" | |
| aria-label={label} | |
| aria-orientation="vertical" | |
| aria-valuemin={0} | |
| aria-valuemax={800} | |
| aria-valuenow={open ? width : 0} | |
| tabIndex={0} | |
| title={label} | |
| onPointerDown={onPointerDown} | |
| onPointerMove={onPointerMove} | |
| onPointerUp={onPointerUp} | |
| onPointerCancel={onPointerCancel} | |
| onClick={onClick} | |
| onKeyDown={onKeyDown} | |
| > | |
| <span className="sidebar-resizer-line" aria-hidden="true" /> | |
| <span className="sidebar-resizer-tab" aria-hidden="true">{arrow}</span> | |
| </div> | |
| ); | |
| } | |