Spaces:
Running
Running
| import { useEffect, useState } 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; | |
| export function BonsaiShell(props: BonsaiShellProps) { | |
| const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); | |
| const [rightSidebarOpen, setRightSidebarOpen] = useState(true); | |
| 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 activeModel = props.models.find((model) => model.id === props.activeModelId); | |
| const busy = props.streamState === 'loading' || props.streamState === 'streaming'; | |
| const resolvedTheme = resolveThemeMode(themeMode, systemDark); | |
| 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 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`); | |
| } | |
| 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={() => setLeftSidebarOpen((open) => !open)} | |
| onToggleRightSidebar={() => setRightSidebarOpen((open) => !open)} | |
| onToggleChatWidth={() => setChatWidth((width) => width === 'centered' ? 'wide' : 'centered')} | |
| /> | |
| <div className={`workspace-grid layout-${chatWidth}${leftSidebarOpen ? '' : ' is-models-collapsed'}${rightSidebarOpen ? '' : ' is-inspector-collapsed'}`}> | |
| <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} | |
| onCancel={props.onCancel} | |
| onToolsEnabledChange={props.onToolsEnabledChange} | |
| onOpenConversationList={props.onOpenConversationList} | |
| onOpenSettings={props.onOpenSettings} | |
| />} | |
| <InspectorPanel | |
| hidden={!rightSidebarOpen} | |
| telemetry={props.telemetry} | |
| toolEvents={props.toolEvents} | |
| artifact={props.artifact} | |
| onCloseArtifact={props.onCloseArtifact} | |
| /> | |
| </div> | |
| <SettingsSheet | |
| open={props.settingsOpen} | |
| themeMode={themeMode} | |
| onThemeModeChange={setThemeMode} | |
| activeModel={activeModel} | |
| telemetry={props.telemetry} | |
| settings={props.settings} | |
| onSettingsChange={props.onSettingsChange} | |
| onPersistStorage={props.onPersistStorage} | |
| onClearStorage={props.onClearStorage} | |
| onClearConversation={props.onClearConversation} | |
| onClose={props.onCloseSettings} | |
| /> | |
| </div> | |
| ); | |
| } | |