Bonsai-Chat-WebGPU / src /app /components /BonsaiShell.tsx
Valeriy Selitskiy
Release v1.2.2 interactive chat workspace
21ad36a
Raw
History Blame Contribute Delete
6.08 kB
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>
);
}