From 50481deaa1f7d17184500bb6f5abb3e1a0c8bbd5 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 10:23:09 +0800 Subject: [PATCH] fix: let story graph views fill the canvas column with compact controls --- .../conversation-supervision/progress.md | 8 +- .../conversation-supervision/ui-design.md | 2 +- src/renderer/src/HeartbeatCenter.test.tsx | 2 +- src/renderer/src/StoryGraph3D.tsx | 89 +++++-- src/renderer/src/SupervisorWorkspace.test.tsx | 5 + src/renderer/src/SupervisorWorkspace.tsx | 204 +++++++-------- .../src/i18n/locales/en-US/heartbeat.ts | 20 +- .../src/i18n/locales/zh-CN/heartbeat.ts | 20 +- src/renderer/src/supervisor-workspace.css | 245 ++++++++++++++++-- tests/support/supervisor-layout-driver.mjs | 42 ++- 10 files changed, 451 insertions(+), 186 deletions(-) diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 9be43351..84c5ae53 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -69,7 +69,7 @@ 实现: - `experienceLinks()`(`story-graph-3d-model.ts`):经验在当前层中,从最早形成依据所在的木片,连到之后第一个在另一片木片上的应用。只在同一片木片里用过的、应用不晚于形成的、依据不在当前层的经验不画。每层最多 6 条,按应用到的不同木片数排序。 -- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。画布下方新增“跨木片的经验”列表,可用键盘选择;点击菱形或列表项打开经验详情。 +- 时间螺旋:经验画成桶外的菱形,高度在形成与应用两个时间之间,角度在两片木片之间;两段向外弯的弧线分别连到形成木片和应用木片的对应高度。只有选中的经验在画布上显示名称。提供“跨木片的经验”键盘入口(现为工具栏选择框);点击菱形或选择经验打开详情。 验证: @@ -160,7 +160,11 @@ - 图谱画布标题右侧新增“平铺 / 时间螺旋”切换。时间螺旋视图(`StoryGraph3D.tsx`)读取故事列表与图谱返回的 `attention`:高度为时间,半径为每圈的对话轮次(一圈一个半径,相邻圈差值受限),木片为故事起止,事件按屏幕距离聚合。层级为 全部 › 项目 › 功能 › 子线索,跨项目故事在根层;点击木片进入下一层并在右侧显示故事详情,单个事件选中后显示事件详情。斜俯视/侧视/俯视/自由视角,拖动、滚轮、方向键、数字键、Esc。 - 计算部分在 `story-graph-3d-model.ts`(树、角度、圈长、半径、聚合),渲染只负责显示。圈长从候选档位中选最接近 8 圈且不超过 14 圈的一档。 -- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图;画布下方以列表重复当前层木片,供键盘与读屏使用。 +- Three.js `0.186.1` 作为依赖,组件通过 `lazy` 加载,只在打开时间螺旋时下载(生产构建中只出现在 `StoryGraph3D` 分块)。颜色取现有 `--graph-node-*` 令牌。WebGL 不可用时提示改用平铺视图。 +- 2026-10-02 布局调整:画布下方的图例段落和木片、经验列表移除,中间区域只保留图。图例改为工具栏“图例”按钮,点击后浮在画布右上角,可用 X 关闭;圈长(如“一圈 1 天 · 共 13.1 圈”)常驻画布左下角。木片和经验的键盘与读屏入口改为工具栏中的两个选择菜单(共享 `AnchoredMenu` 样式,每项显示名称和事件数、起止或来源→去向)。按钮按当前层级命名,如“项目与跨项目故事 · 6”“功能 · 15”“子线索 · 9”“经验 · 3”,不再使用“木片”这一内部比喻。菜单内边距 4px,菜单项 8px×12px、行间 2px,两个按钮间距 8px;1440 与 390 宽度下文字无裁切,菜单在窗口内。 +- 2026-10-03 图谱中间列不再滚动:平铺与时间螺旋都把标题栏以外的高度全部给图。平铺视图的图例、阅读说明移入标题栏“图例”浮层,可见数量常驻画布左下角;“事件浏览”改为单行(上一个、滑块、下一个、位置、当前事件)。图谱行最低 660px,以免平铺图文字小于 11px;更矮的窗口改为页面滚动。Electron 布局测试新增断言:中间列 `overflow: hidden` 且无溢出,图区一直延伸到列底部;1440×1200 下整页无滚动、底部留 33px,图高 625px。真实数据副本下时间螺旋在 700px 行高中填满,无列内滚动。 +- 2026-10-03 图谱页顶部的“图谱范围 · 全局”和时间行并入画布标题下方一行(范围 · 时间区间 · 生成时间 · 事件/实体数),刷新改为标题栏图标按钮;标题由“工作的来路”改为“事件与知识”。图谱行因此多出约 40px:1440×900 下整页不再滚动,图高 552px。布局测试新增断言:范围行位于标题下方,图谱上方不再有独立的范围栏。 +- 2026-10-03 修复“时间螺旋没有加载成功”:每次重建场景都复用同一个画布,而上一次释放时已对它调用 `forceContextLoss()`,该画布再也拿不到可用的 WebGL 上下文(Three.js 报 `Cannot read properties of null (reading 'precision')`)。开发模式的 StrictMode 双挂载、父组件刷新时传入新的 `attention` 数组都会触发重建,所以打开就失败。现在每次重建创建独立的绘图画布并在释放时移除,原画布只负责焦点和指针事件;`attention` 按内容保持稳定,刷新不再重建场景。复现脚本(StrictMode + 20 次父组件重渲染)修复前首屏即报错,修复后无报错。真实数据副本的 Electron 检查(1440 浅色、390 深色):画布外无段落和列表,图例浮层位于画布内,选择框逐层打开、视角切换、Esc 返回正常,无报错和横向溢出。 验证: diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index c06e3386..8100d795 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -103,7 +103,7 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。 -高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 420px。三栏时整页不再出现纵向滚动;左侧列表、中间画布、右侧详情各自在这一行内独立滚动,滚动只影响自身,不带动页面。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。 +高度:容器宽度不小于 861px 时,图谱这一行的高度等于窗口可见高度减去图谱顶部位置,并在底部保留约 24px 边距(不小于页面底部内边距),最低 660px(再矮平铺图的文字会小于 11px,这时改为页面滚动)。三栏时整页不再出现纵向滚动;左侧列表和右侧详情在这一行内独立滚动,滚动只影响自身,不带动页面。中间画布列不出现滚动条:标题栏和底部单行“事件浏览”栏之外的高度全部给图,平铺与时间螺旋一致。画布标题为“事件与知识”,下方一行小字依次显示图谱范围、时间区间、生成时间和事件/实体数,刷新为标题栏右侧图标按钮;图谱上方不再单独占一行显示范围与时间。没有图谱数据时,刷新按钮保留在页签顶部。图例默认收起,从标题栏“图例”按钮打开,浮在画布右上角,可用 X 关闭;可见事件与实体数量常驻画布左下角。两栏(861~1190px)时第一行按同样方式填满,详情换到下一行,保持自然高度并随页面滚动。单栏保持原有页面滚动和列表 320px 上限。窗口大小或图谱上方内容(提示、工具栏)变化时重新计算。 实体及其影响连线复用现有 `--graph-node-*-border` 令牌;颜色用于辅助辨认实体,不代表故事分类或确认状态。选中项加粗并突出连线,其余节点降低图形强调度但保留可读文字。详情分为名称与说明、图谱关联、确认操作和来源,来源正文按需读取。图例常显,读图说明可展开。 diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index ead10d0c..02cd46d9 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -257,7 +257,7 @@ describe('HeartbeatCenter', () => { } expect(within(panel).getByRole('region', { name: t('supervisor.canvas') })).toBeVisible() expect(within(panel).getByRole('heading', { name: t('supervisor.inspector') })).toBeVisible() - expect(within(panel).getByText(`${t('supervisor.graphScope')}: ${t('center.scope.global')}`)).toBeVisible() + expect(within(panel).getByText(new RegExp(`^${t('supervisor.graphScope')}: ${t('center.scope.global')} · `))).toBeVisible() } if (key === 'activity.title') { await screen.findByText(t('activity.empty')) diff --git a/src/renderer/src/StoryGraph3D.tsx b/src/renderer/src/StoryGraph3D.tsx index a095e311..5eec42e6 100644 --- a/src/renderer/src/StoryGraph3D.tsx +++ b/src/renderer/src/StoryGraph3D.tsx @@ -1,4 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Info, X } from 'lucide-react' +import { AnchoredMenu } from './AnchoredMenu' import { useTranslation } from 'react-i18next' import * as THREE from 'three' import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts' @@ -32,16 +34,39 @@ const toneVariables = ['--graph-node-1-border', '--graph-node-2-border', '--grap function cssColor(element: Element, variable: string, fallback: string): string { return getComputedStyle(element).getPropertyValue(variable).trim() || fallback } +type PickerItem = { id: string; title: string; detail: string; current: boolean; onPick: () => void } +/** Toolbar picker in the shared anchored-menu style: a title and a secondary line per item. */ +function PickerMenu({ label, button, items }: { label: string; button: string; items: PickerItem[] }) { + const [open, setOpen] = useState(false) + const anchorRef = useRef(null) + const id = useId() + return <> + + {open && setOpen(false)}> + {items.map(item => )} + } + +} function webglAvailable(): boolean { try { const canvas = document.createElement('canvas'); return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')) } catch { return false } } -export default function StoryGraph3D({ stories, attention, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) { +export default function StoryGraph3D({ stories, attention: attentionProp, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) { const { t, i18n } = useTranslation('heartbeat') const canvasRef = useRef(null) const overlayRef = useRef(null) const [supported] = useState(webglAvailable) + // Refreshes deliver equal data in new arrays; keep the previous array so the scene is not recreated. + const attentionKey = JSON.stringify(attentionProp) + const attention = useMemo(() => attentionProp, [attentionKey]) // eslint-disable-line react-hooks/exhaustive-deps // 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) @@ -50,6 +75,8 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe const focus = findNode(tree, focusId) ?? tree const [view, setView] = useState('oblique') const [hovered, setHovered] = useState() + const [legendOpen, setLegendOpen] = useState(false) + const legendId = useId() const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 }) const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined) const range = useMemo(() => { @@ -66,12 +93,18 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe useEffect(() => { const canvas = canvasRef.current, overlay = overlayRef.current if (!supported || !canvas || !overlay || !range) return + // Each scene draws on a fresh canvas: a canvas whose context was released on dispose cannot give a working context again. + // `canvas` stays as the focus and pointer surface. + const glCanvas = document.createElement('canvas') + glCanvas.className = 'story-graph-3d__gl' + glCanvas.setAttribute('aria-hidden', 'true') + canvas.before(glCanvas) // 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 } + try { renderer = new THREE.WebGLRenderer({ canvas: glCanvas, antialias: true, alpha: true }) } + catch (error) { glCanvas.remove(); console.error('GoodBuddy 3D view failed', error); queueMicrotask(() => setFailure('create')); return } const lost = (event: Event) => { event.preventDefault(); setFailure('lost') } - canvas.addEventListener('webglcontextlost', lost) + glCanvas.addEventListener('webglcontextlost', lost) renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) const scene = new THREE.Scene() const host = canvas.parentElement! @@ -241,8 +274,8 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe link: hit.object.userData.link as ExperienceLink | undefined } }, // 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() } + dispose: () => { canvas.removeEventListener('wheel', zoom); glCanvas.removeEventListener('webglcontextlost', lost); resize.disconnect() + disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss(); glCanvas.remove() } } return () => { engine.current?.dispose(); engine.current = undefined } // Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene. @@ -280,6 +313,9 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe if (!range) return

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

+ const childKinds = new Set(level.children.map(node => node.level)) + const childLevelName = t(`supervisor.graph3d.childLevels.${childKinds.size === 1 ? [...childKinds][0] : 'mixed'}`) + const scale = t('supervisor.graph3d.scale', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 }) return
@@ -334,19 +381,21 @@ export default function StoryGraph3D({ stories, attention, selectedEventId, onSe event.preventDefault(); setView('free'); move[event.key]!(); engine.current?.draw() }} />
-

{t('supervisor.graph3d.legend', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })}

- {/* The same staves as a list, for keyboard and screen readers. */} - {shownLinks.length > 0 &&
    - {shownLinks.map(link =>
  • )} -
} -
    - {level.children.map(node =>
  • )} -
} diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index 84561939..b50cb022 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -306,7 +306,12 @@ describe('SupervisorWorkspace', () => { expect(within(canvas).queryByRole('heading', { name: '时间事件' })).not.toBeInTheDocument() expect(within(canvas).getByRole('group', { name: '故事线图谱' })).toHaveAttribute('width', '100%') expect(canvas.querySelector('.supervisor-workspace__timeline-ring')).toHaveAttribute('marker-end') + // The legend is closed by default and opens on the canvas, so the graph keeps the column. + expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument() + fireEvent.click(within(canvas).getByRole('button', { name: '图例' })) expect(within(canvas).getByRole('list', { name: '图谱图例' })).toBeInTheDocument() + fireEvent.click(within(canvas).getByRole('button', { name: '关闭图例' })) + expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument() expect(within(canvas).getByRole('slider', { name: '事件浏览' })).toBeDisabled() expect(graph.querySelector('.supervisor-workspace__entity text')).toHaveTextContent('方案') expect(graph.querySelector('.supervisor-workspace__node text')).toHaveTextContent('决定') diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index 75140b8a..304c90c0 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -1,7 +1,7 @@ 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 { BookOpen, ChevronLeft, ChevronRight, Ellipsis, Info, Network, RefreshCw, X } from 'lucide-react' import { AnchoredMenu } from './AnchoredMenu' import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts' import { EmptyState, PageTabs } from './WorkspacePrimitives' @@ -42,6 +42,9 @@ type Props = { onOpenConversation?: (conversationId: string) => void onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void } +const GRAPH_MIN_HEIGHT = 660 +// Stable empty value: a new array per render would rebuild the 3D scene on every refresh. +const noAttention: NonNullable = [] const emptyGraph: SupervisionGraphView = { storyLine: null, events: [], @@ -84,6 +87,7 @@ export function SupervisorWorkspace({ const [selection, setSelection] = useState(graphNavigation?.focus) const [listTab, setListTab] = useState(graphNavigation?.focus?.kind ?? 'event') const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat') + const [flatLegendOpen, setFlatLegendOpen] = useState(false) const [source, setSource] = useState<{ id: string title: string @@ -491,7 +495,9 @@ export function SupervisorWorkspace({ : undefined const storyState = useSupervisionStories(graphScope, tab === 'graph', graph) const graphLayoutRef = useRef(null) - useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0) + // The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat + // graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead. + useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT) // The work review reads the selected result's own scope, so it matches the period shown. const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id) const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined @@ -694,20 +700,9 @@ export function SupervisorWorkspace({ )} {tab === 'graph' && ( <> -
- {(graphScope || latest) &&
- {graphScope && ( -

- {t('supervisor.graphScope')}: {scopeText(graphScope)} -

- )} - {latest && ( -

- {date(latest.timeRange.from)} –{' '} - {date(latest.timeRange.to)} · {date(latest.createdAt)} -

- )} -
} + {/* With a graph, scope and refresh live in the canvas heading; without one, keep refresh reachable here. */} + {!graph.events.length &&
+ -
+
} {!loading && !loadError && !graph.events.length && ( } @@ -846,13 +841,26 @@ export function SupervisorWorkspace({ aria-label={t('supervisor.canvas')} >
- {t('supervisor.canvasTitle')} - - {t('supervisor.counts', { - events: layout.events.length, - entities: layout.entities.length - })} - + {/* Title, then one line with scope, period, generation time and counts. */} +
+ {t('supervisor.canvasTitle')} + + {[ + graphScope && `${t('supervisor.graphScope')}: ${scopeText(graphScope)}`, + latest && `${date(latest.timeRange.from)} – ${date(latest.timeRange.to)}`, + latest && t('supervisor.generatedAt', { time: date(latest.createdAt) }), + t('supervisor.counts', { events: layout.events.length, entities: layout.entities.length }) + ].filter(Boolean).join(' · ')} + +
+ + {graphMode === 'flat' &&
+ {selection && } + +
} {storyState.available &&
{(['flat', 'spiral'] as const).map((mode) => )} @@ -865,7 +873,7 @@ export function SupervisorWorkspace({
}> {t('supervisor.loading')}

}> - { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }} onSelectStory={(id) => select({ kind: 'story', id })} @@ -1057,79 +1065,42 @@ export function SupervisorWorkspace({ ))}
-
- {t('supervisor.canvasNote')} -
-
- - {t('supervisor.readingGuide')} ·{' '} - {t('supervisor.visibleCounts', { - events: layout.visibleEvents.length, - entities: layout.visibleEntities.length - })} - -

- {t('supervisor.canvasCaption')} -

-
+

{t('supervisor.canvasCaption')}

+ {/* Reading aids float on the canvas so the graph keeps the whole column. */} + + {flatLegendOpen &&
+
+ {t('supervisor.graph3d.legendTitle')} + +
+
    +