Bonsai-Chat-WebGPU / src /app /components /BonsaiShell.tsx
WaveCut's picture
Keep model resident on cancel and harden artifact tools
643dfc6 verified
Raw
History Blame Contribute Delete
18.7 kB
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>
);
}