From a1a1d24022178d080d5de73ff995785c9b50329a Mon Sep 17 00:00:00 2001
From: mesalogo
Date: Fri, 2 Oct 2026 22:58:30 +0800
Subject: [PATCH] fix: release the spiral view's WebGL context and keep its
failures local
Each open of the time spiral created a WebGL context that was never released, so repeated use hit the browser limit and lost the oldest context. The view now releases it on close, shows a local retry on creation failure or context loss, and the lazy chunk retries once before an in-canvas error.
---
src/renderer/src/StoryGraph3D.tsx | 22 +++++++++++++++----
src/renderer/src/SupervisorWorkspace.tsx | 21 ++++++++++++++++--
.../src/i18n/locales/en-US/heartbeat.ts | 5 ++++-
.../src/i18n/locales/zh-CN/heartbeat.ts | 5 ++++-
4 files changed, 45 insertions(+), 8 deletions(-)
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 })} />
+
) : <>