diff --git a/src/renderer/src/StoryGraph3D.tsx b/src/renderer/src/StoryGraph3D.tsx index f0199722..a095e311 100644 --- a/src/renderer/src/StoryGraph3D.tsx +++ b/src/renderer/src/StoryGraph3D.tsx @@ -42,6 +42,9 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe const canvasRef = useRef(null) const overlayRef = useRef(null) const [supported] = useState(webglAvailable) + // Context creation failed or the GPU dropped the context; `attempt` recreates the scene on retry. + const [failure, setFailure] = useState<'create' | 'lost'>() + const [attempt, setAttempt] = useState(0) const tree = useMemo(() => buildStoryTree(stories, new Map()), [stories]) const [focusId, setFocusId] = useState('root') const focus = findNode(tree, focusId) ?? tree @@ -63,7 +66,12 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe useEffect(() => { const canvas = canvasRef.current, overlay = overlayRef.current if (!supported || !canvas || !overlay || !range) return - const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) + // Under load the browser can refuse a new WebGL context; show a local error instead of failing the page. + let renderer: THREE.WebGLRenderer + try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) } + catch (error) { console.error('GoodBuddy 3D view failed', error); queueMicrotask(() => setFailure('create')); return } + const lost = (event: Event) => { event.preventDefault(); setFailure('lost') } + canvas.addEventListener('webglcontextlost', lost) renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) const scene = new THREE.Scene() const host = canvas.parentElement! @@ -232,11 +240,13 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe return hit && { node: hit.object.userData.node as StoryNode | undefined, cluster: hit.object.userData.cluster as StoryCluster | undefined, link: hit.object.userData.link as ExperienceLink | undefined } }, - dispose: () => { canvas.removeEventListener('wheel', zoom); resize.disconnect(); disposables.forEach(item => item.dispose()); renderer.dispose() } + // Release the GPU context too: browsers keep only a few, and reopening the view must not use them up. + dispose: () => { canvas.removeEventListener('wheel', zoom); canvas.removeEventListener('webglcontextlost', lost); resize.disconnect() + disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss() } } return () => { engine.current?.dispose(); engine.current = undefined } - // Focus, hover and selection are pushed through update() below; only data and locale recreate the scene. - }, [supported, range, attention, i18n.resolvedLanguage]) // eslint-disable-line react-hooks/exhaustive-deps + // Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene. + }, [supported, range, attention, i18n.resolvedLanguage, attempt]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) }, [focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId]) @@ -265,6 +275,10 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe return [event.clientX - rect.left, event.clientY - rect.top] as const } if (!supported) return

{t('supervisor.graph3d.unsupported')}

+ if (failure) return
+ {t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')} + +
if (!range) return

{t('supervisor.stories.empty')}

return
diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index 7dbe97d1..75140b8a 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -1,5 +1,6 @@ -import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { Component, lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import type { ReactNode } from 'react' import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react' import { AnchoredMenu } from './AnchoredMenu' import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts' @@ -19,7 +20,17 @@ import { } from '../../shared/supervision-contracts' // Three.js loads only when the spiral view is opened. -const StoryGraph3D = lazy(() => import('./StoryGraph3D')) +// A failed chunk load (busy disk or renderer, interrupted update) is retried once before reporting. +const StoryGraph3D = lazy(() => import('./StoryGraph3D').catch(() => new Promise((resolve, reject) => + setTimeout(() => { import('./StoryGraph3D').then(resolve, reject) }, 800)))) + +/** Keeps a failure of the spiral view inside the canvas; the flat view, lists and page stay usable. */ +class SpiralBoundary extends Component<{ children: ReactNode; fallback: (retry: () => void) => ReactNode }, { failed: boolean }> { + state = { failed: false } + static getDerivedStateFromError(): { failed: boolean } { return { failed: true } } + componentDidCatch(error: unknown): void { console.error('GoodBuddy spiral view failed', error) } + render(): ReactNode { return this.state.failed ? this.props.fallback(() => this.setState({ failed: false })) : this.props.children } +} type Selection = { kind: 'event' | 'entity' | 'relation' | 'story' | 'experience'; id: string } /** `focus` opens a story or experience in the graph lists, such as from a heartbeat suggestion. */ export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph'; focus?: { kind: 'story' | 'experience'; id: string } } @@ -848,6 +859,11 @@ export function SupervisorWorkspace({
}
{graphMode === 'spiral' ? ( +
+ {t('supervisor.graph3d.failed')} + + +
}> {t('supervisor.loading')}

}> select({ kind: 'experience', id })} />
+
) : <>