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.
This commit is contained in:
mesalogo
2026-10-02 22:58:30 +08:00
parent 9d8477b728
commit a1a1d24022
4 changed files with 45 additions and 8 deletions
+18 -4
View File
@@ -42,6 +42,9 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(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 <p className="supervisor-workspace__muted" role="status">{t('supervisor.graph3d.unsupported')}</p>
if (failure) return <div className="supervisor-workspace__inline-error" role="alert">
<strong>{t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={() => { setFailure(undefined); setAttempt(value => value + 1) }}>{t('supervisor.graph3d.retry')}</button>
</div>
if (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
return <div className="story-graph-3d">
<div className="story-graph-3d__toolbar">
+19 -2
View File
@@ -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<typeof import('./StoryGraph3D')>((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({
</div>}
</div>
{graphMode === 'spiral' ? (
<SpiralBoundary fallback={(retry) => <div className="supervisor-workspace__inline-error" role="alert">
<strong>{t('supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={retry}>{t('supervisor.graph3d.retry')}</button>
<button type="button" className="link-button" onClick={() => setGraphMode('flat')}>{t('supervisor.graph3d.modes.flat')}</button>
</div>}>
<Suspense fallback={<p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p>}>
<StoryGraph3D stories={storyState.view.stories} attention={graph.attention ?? []}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
@@ -857,6 +873,7 @@ export function SupervisorWorkspace({
selectedExperienceId={selection?.kind === 'experience' ? selection.id : undefined}
onSelectExperience={(id) => select({ kind: 'experience', id })} />
</Suspense>
</SpiralBoundary>
) : <>
<figure className="supervisor-workspace__figure">
<div
@@ -122,7 +122,10 @@ export const heartbeat = {
experiences: 'Experiences across staves',
legend: 'Height is time, one turn is {{turn}} ({{count}} turns); radius is conversation density at the time and only shows how concentrated the work was. A stave spans its story from first to last event. Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.',
turns: { 3: '3 hours', 6: '6 hours', 12: '12 hours', 24: '1 day', 168: '1 week', 720: '1 month', 2160: '1 quarter', 8760: '1 year' },
unsupported: 'This device cannot show 3D. Use the flat view and the story list.'
unsupported: 'This device cannot show 3D. Use the flat view and the story list.',
failed: 'The time spiral did not load, possibly because the system is busy. The lists and the flat view still work.',
lost: 'The graphics card interrupted the time spiral. The lists and the flat view still work.',
retry: 'Reload the time spiral'
},
stories: {
empty: 'No stories yet. After the next review, events are grouped into features and sub-threads under each project.',
@@ -119,7 +119,10 @@ export const heartbeat = {
experiences: '跨木片的经验',
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。桶外的菱形是经验,弧线从形成它的木片连到后来用上它的木片。',
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。'
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
failed: '时间螺旋没有加载成功,可能是系统正忙。左侧列表和平铺视图仍可使用。',
lost: '显卡暂时中断了时间螺旋的绘制。左侧列表和平铺视图仍可使用。',
retry: '重新加载时间螺旋'
},
stories: {
empty: '还没有故事。下一次回顾完成后,会把事件归入项目下的功能和子线索。',