fix: retain historical events in the story spiral

Render every selected review event, including events without current story membership, and label current grouping without inventing historical assignments. Keep history selection inside the central canvas heading, use shared camera controls, and fit the default scene within the viewport.

Validation: 46 focused tests, synthetic and read-only portable-data Electron checks, Node/Agent/Web typechecks, targeted ESLint, and diff checks passed. All 21 reported events remain represented at wide and narrow widths; no live data writes or model calls.

Release note: Historical story spirals now retain unassigned events. Review history sits inside the canvas column, and camera controls have consistent spacing and framing.
This commit is contained in:
mesalogo
2026-10-04 21:09:32 +08:00
parent 49fd0d3054
commit b9186a616c
15 changed files with 262 additions and 44 deletions
@@ -4,6 +4,16 @@
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
## 2026-10-04 历史螺旋缺失事件与工具栏
读取仓库便携版 `data/assistant.sqlite`,用只读连接及 SQLite online backup 保存到系统临时 `opencode` 目录,后续查询只读备份。报告对应结果 `b1ec5e99-5a72-444b-8c2b-1c4eb4c50960`:本地时间 9 月 28 日 09:48:58 生成,区间 9 月 21 日 09:43:13 至 9 月 28 日 09:43:13,21 个事件、16 个实体。21 个事件均未被 supersede;36 条来源都有 source key、revision 和项目 locator。按同 source key、revision 及片段范围相交检查,没有其他当前事件替代这批来源,排除本例的 ID 过期猜测。
备份时已有 3 个功能故事,均于 10 月 4 日 20:46:32 创建,其中包含该结果的 17 个事件,另 4 个没有故事成员。结果早于故事生成;数据库未保存截图时刻的成员快照,不能声称重现了截图当时的空成员响应。代码中的缺失原因可确定:3D 仅接收当前故事成员,没有所选结果的完整事件输入;无成员时过滤后只剩灰色螺旋,部分有成员时其余事件仍丢失。现在完整事件输入独立于故事成员,显示规则见[故事线模型](./storyline-model-design.md#5-展示与使用)。历史选择移入中间画布标题区,视角改用共享分段控件,并修正默认相机底部裁切。
真实数据验证使用生产 `listSupervisionResults`、`getSupervisionGraph`、故事/经验 Store 查询备份,经本地测试 API 渲染生产 HeartbeatCenter、SupervisorWorkspace 和 StoryGraph3D;没有启动便携版后台服务。`GOODBUDDY_SUPERVISOR_BACKUP` 指定备份路径,`GOODBUDDY_SUPERVISOR_RESULT` 指定结果 ID,运行 `tests/supervisor-layout.electron.test.ts`。1440×1000 浅色和 390×1000 深色均保留 21 个真实事件 ID;根场景分别为 6 个、4 个事件簇,全部几何顶点位于视口内。画布分别为 844×524、309×320,半径 78~152,连续螺旋仍有 1,401 个采样点。历史选择位于中列标题内,工具栏顶部间隔 16px,视角按钮间隔 2px、分段控件内边距 3px、图例间隔 8px,无横向页面溢出、Renderer 错误。事件菜单能逐条定位;额外将测试 API 成员响应置空后,同一批 21 个真实事件仍可在未归属分组查看,数据库未变。
模型与工作区定向回归 46 项、模拟数据螺旋 Electron 回归、上述真实数据 Electron 检查通过;Node/Agent/Web 类型检查、定向 ESLint、diff 检查通过。只截取并读取 1 张初测截图;最终相机修正通过逐顶点投影数值复验,测量保存在临时 `opencode/portable-measurements.json`。未打包替换便携版,未运行全量测试,真实回顾/模型调用为 0,未提交或推送。变更只涉及桌面展示与测试,Agent 和远程 Runtime 路径不受影响。
## 2026-10-04 图谱历史选择与螺旋密度
图谱左上角增加与工作回顾共用的历史选择,默认最新结果,保留显式结果、故事、经验导航及请求代际检查。空图谱与失败状态也保留选择入口。交互规则见 [UI 设计](./ui-design.md#故事线图谱)。
@@ -67,7 +67,7 @@
## 5. 展示与使用
- 故事线图谱新增 3D 视图,与现有平铺视图并列,几何、时间尺度、聚合规则沿用 [3D 概念](../story-graph/3d-concept.md);Three.js 仅在打开 3D 视图时加载,WebGL 不可用时使用平铺视图。
- 结果级图谱与聚合图谱都返回 `attention`。结果级查询按该结果保存的范围及时间区间统计当前消息,螺旋使用同一时间窗口;故事成员与结果事件 ID 求交后绘制,不截断结果事件数组。故事归属是当前记录,重新提取后已失去当前归属的历史事件仍可在平铺视图和事件列表查看,不补造历史故事成员。
- 结果级图谱与聚合图谱都返回 `attention`。结果级查询按该结果保存的范围及时间区间统计当前消息,螺旋使用同一时间窗口。当前故事成员与结果事件按精确 ID 求交,点的名称与时间取所选结果记录。没有当前主故事归属的事件也进入螺旋,按来源确定的 `project_id` 归入显式未归属分组;分组仅用于显示,不持久化为功能故事。重提取后的不同 ID 不通过名称、时间或当前成员反推历史归属,所选事件集合不扩大。界面明确标注当前分组口径,历史故事成员快照仍不可用。
- MCP 增加按故事、按经验查询;经验返回时附“自动归纳 / 用户编辑”标记和依据。
- 心跳建议增加“故事长期无进展”和“已有经验可能适用于当前故事”。两者都由规则选出:前者按设置的天数判断,只提示不改状态;后者要求共享不过于通用的实体。每次心跳各最多 3 条,排在未决事项之前。
- 工作回顾按故事组织:推进了哪些故事、新出现哪些故事、形成或应用了哪些经验。
@@ -111,9 +111,11 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
## 故事线图谱
左上角提供与“工作回顾”共用的“历史结果”选择框,显示生成时间和范围。首次进入默认最新结果;两页之间切换保留所选结果,从侧栏或建议显式打开的结果、故事、经验优先于默认值。切换结果时清除旧来源详情,迟到请求不得覆盖新选择。空图谱及读取失败时仍可切换历史结果。
中间画布列的标题区左上角提供与“工作回顾”共用的“历史结果”选择框,标签与选择框同行,不在整个三栏区域上方另占一行。首次进入默认最新结果;两页之间切换保留所选结果,从侧栏或建议显式打开的结果、故事、经验优先于默认值。选择框显示生成时间和范围;切换时清除旧来源详情,迟到请求不得覆盖新选择。没有画布的空态及读取失败状态仍保留历史入口。
时间螺旋使用所选回顾的时间区间与事件集合,半径含义见 [3D 密度规则](../story-graph/3d-concept.md)。故事与经验列表仍提供该范围的当前完整记录,供查看和调整;画布只绘制属于本次结果的故事事件,避免出现点击后无法在本次事件列表中定位的点。
时间螺旋使用所选回顾的时间区间与完整事件集合,半径含义见 [3D 密度规则](../story-graph/3d-concept.md)。当前故事成员按精确事件 ID 与结果求交;其余事件按来源确定的项目进入“当前无故事归属”分组,项目未知时显示“项目范围不可用”。画布常显“本次回顾事件按当前故事归属分组;未保存当时的故事归属”。故事与经验列表仍提供该范围的当前完整记录,供查看和调整。
根视图直接显示事件点,邻近事件按画布密度聚合并显示数量;“事件”选择菜单保留当前层级的逐条入口,选择后同步左侧列表与详情。视角位于画布工具栏右上角,复用共享 SegmentedControl;工具栏四周使用 16px 内边距,视角与图例间隔 8px,窄屏按需换行。
关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,使用上述可编辑正文预览,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。
+1 -1
View File
@@ -8,7 +8,7 @@
每圈时长从 3、6、12、24、168、720、2160、8760 小时中选择,在不超过 14 圈的候选中取最接近 8 圈的一档。生产界面自动选择粒度;手动指定仍是 Demo 能力。不足一圈时保留实际圈数,时间区间不因显示需要而扩大。
同一故事中屏幕上过于接近的事件合并为一段,显示事件数量。点击聚合段,查看范围缩小到这段时间,事件随之拆开;小于约 6 小时仍无法拆开的事件(同一时刻多次提取),改为在右侧列出。
生产视图将同一分组中屏幕上过于接近的事件合并为一段,显示事件数量;缩放后重新计算聚合。根视图也绘制事件点,点击聚合点进入对应分组,“事件”菜单可逐条选择当前层级的记录并打开详情。没有当前故事归属的事件使用显式未归属分组,数据口径见[故事线模型](../conversation-supervision/storyline-model-design.md#5-展示与使用)。Demo 另提供点击聚合段缩小时间范围的交互,生产视图尚未实现该自动缩放。
[3D 网页 Demo](3d-demo.html) 已按上述几何规则和时间尺度实现,数据为本机故事线图谱的只读导出。
+21 -13
View File
@@ -1,9 +1,10 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Info, X } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { SegmentedControl } from './WorkspacePrimitives'
import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
@@ -14,6 +15,8 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels,
*/
type Props = {
stories: SupervisionStory[]
events: SupervisionGraphView['events']
projectNames: Map<string, string>
attention: SupervisionAttentionSlot[]
timeRange?: { from: string; to: string }
selectedEventId?: string
@@ -60,7 +63,7 @@ function webglAvailable(): boolean {
catch { return false }
}
export default function StoryGraph3D({ stories, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -71,7 +74,8 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
// 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 tree = useMemo(() => buildStoryTree(stories, projectNames, { events,
unassigned: t('supervisor.graph3d.unassigned'), unknownProject: t('supervisor.graph3d.unknownProject') }), [stories, events, projectNames, t])
const [focusId, setFocusId] = useState('root')
const focus = findNode(tree, focusId) ?? tree
const [view, setView] = useState<ViewName>('oblique')
@@ -145,7 +149,7 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
const groups = new Map<string, StoryCluster[]>()
const wisdom = cssColor(host, '--graph-node-4-border', '#b45309')
const world = new THREE.Group(); scene.add(world)
const clusterSpan = () => CLUSTER_PX / (HEIGHT * Math.min(width / 500, height / 560) * camera.current.zoom) * (range.to - range.from)
const clusterSpan = () => CLUSTER_PX / (HEIGHT * Math.min(width / 560, height / 660) * camera.current.zoom) * (range.to - range.from)
let builtSpan = -1
const rebuild = () => {
world.clear()
@@ -173,12 +177,11 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
const active = cluster.events.some(event => event.id === state.selected)
world.add(tube(arc, (Math.min(6, 1.3 + Math.log2(cluster.events.length) * 1.1) + (active ? 2 : 0)) * 0.45, solid(color, emphasis === 'dim' ? 0.3 : 0.9), 20))
}
// Connectors and the story's own colour on the helix only for the focused or hovered stave.
if (emphasis !== 'focus') continue
// Every event is represented at the root; connectors emphasize focus and selection only.
for (const cluster of clusters) {
const p = helix(cluster.t), active = cluster.events.some(event => event.id === state.selected)
world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color))
if (emphasis === 'focus' || active) world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color, emphasis === 'dim' ? 0.3 : 1))
dot.position.copy(p); dot.userData.cluster = cluster; dot.userData.node = stave
world.add(dot); meshes.push(dot)
}
@@ -207,7 +210,8 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
view3d.position.copy(target).add(new THREE.Vector3(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST))
view3d.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st)
view3d.lookAt(target)
view3d.fov = 2 * Math.atan(height / Math.min(width / 500, height / 560) / 2 / DIST) * 180 / Math.PI
// Leave room for the near rim in the default tilted perspective, including its lower edge.
view3d.fov = 2 * Math.atan(height / Math.min(width / 560, height / 660) / 2 / DIST) * 180 / Math.PI
view3d.aspect = width / height; view3d.zoom = zoom; view3d.updateProjectionMatrix()
}
const draw = () => {
@@ -300,7 +304,7 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
}
const choose = (name: ViewName) => { setView(name); if (name !== 'free') animate({ ...VIEWS[name], zoom: 1, pan: 0 }) }
const open = (node: StoryNode) => {
if (node.level !== 'project') onSelectStory(node.id)
if (node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setFocusId(node.id)
setHovered(undefined)
}
@@ -327,9 +331,9 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
: <button type="button" className="link-button" onClick={() => setFocusId(node.id)}>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</button>}
</span>)}
</nav>
<div role="group" aria-label={t('supervisor.graph3d.views')}>
{(['oblique', 'side', 'top', 'free'] as const).map(name => <button key={name} type="button" className="secondary-button"
aria-pressed={view === name} onClick={() => choose(name)}>{t(`supervisor.graph3d.view.${name}`)}</button>)}
<div className="story-graph-3d__views">
<SegmentedControl ariaLabel={t('supervisor.graph3d.views')} value={view} onChange={choose}
options={(['oblique', 'side', 'top', 'free'] as const).map(value => ({ value, label: t(`supervisor.graph3d.view.${value}`) }))} />
<button type="button" className="secondary-button" aria-expanded={legendOpen} aria-controls={legendId}
onClick={() => setLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>
@@ -338,10 +342,14 @@ export default function StoryGraph3D({ stories, attention: attentionProp, timeRa
<PickerMenu label={t('supervisor.graph3d.staves')} button={t('supervisor.graph3d.pickChildren', { level: childLevelName, count: level.children.length })}
items={level.children.map(node => ({ id: node.id, title: node.name, detail: `${t('supervisor.stories.count', { count: node.events.length })} · ${dateText(node.start)} – ${dateText(node.end)}`,
current: node.id === focus.id, onPick: () => open(node) }))} />
<PickerMenu label={t('supervisor.events')} button={t('supervisor.graph3d.pickEvents', { count: focus.events.length })}
items={focus.events.map(event => ({ id: event.id, title: event.title, detail: dateText(event.t),
current: event.id === selectedEventId, onPick: () => onSelectEvent(event.id) }))} />
{shownLinks.length > 0 && <PickerMenu label={t('supervisor.graph3d.experiences')} button={t('supervisor.graph3d.pickExperience', { count: shownLinks.length })}
items={shownLinks.map(link => ({ id: link.id, title: link.statement, detail: `${link.from.stave.name} → ${link.to.stave.name}`,
current: link.id === selectedExperienceId, onPick: () => onSelectExperience?.(link.id) }))} />}
</div>
<span className="supervisor-workspace__muted">{t('supervisor.graph3d.membershipNote')}</span>
</div>
<div className="story-graph-3d__viewport">
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
@@ -44,6 +44,7 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={navigation} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__graph-canvas')).not.toBeNull()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ ...navigation }} />)
+5 -8
View File
@@ -474,11 +474,7 @@ export function SupervisorWorkspace({
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
// Story membership is current and scope-wide; only plot events selectable in this review.
const spiralStories = useMemo(() => {
const ids = new Set(graph.events.map(event => event.id))
return storyState.view.stories.map(story => ({ ...story, events: story.events.filter(event => ids.has(event.id)) }))
}, [storyState.view.stories, graph.events])
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
const graphLayoutRef = useRef<HTMLDivElement>(null)
// 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.
@@ -654,8 +650,8 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
{historySelector}
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
{!graph.events.length && historySelector}
{!loading && !loadError && !graph.events.length && (
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
<EmptyState
@@ -797,8 +793,9 @@ export function SupervisorWorkspace({
aria-label={t('supervisor.canvas')}
>
<div className="supervisor-workspace__canvas-heading">
{/* Title, then one line with scope, period, generation time and counts. */}
{/* History stays inside the canvas column with its title and review metadata. */}
<div className="supervisor-workspace__canvas-title">
{historySelector}
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{[
@@ -829,7 +826,7 @@ export function SupervisorWorkspace({
<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={spiralStories} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
<StoryGraph3D stories={storyState.view.stories} events={graph.events} projectNames={projectNames} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
@@ -146,7 +146,10 @@ export const heartbeat = {
experience: 'Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.'
},
// Picker buttons name what the list holds at the current level.
childLevels: { project: 'Projects', feature: 'Features', thread: 'Sub-threads', cross: 'Cross-project stories', mixed: 'Projects and cross-project stories' },
unassigned: 'No current story membership', unknownProject: 'Project unavailable',
membershipNote: 'Events from this review, grouped by current story membership. Historical membership was not saved.',
pickEvents: 'Events · {{count}}',
childLevels: { project: 'Projects', feature: 'Features', thread: 'Sub-threads', cross: 'Cross-project stories', unassigned: 'Unassigned events', mixed: 'Groups' },
pickChildren: '{{level}} · {{count}}', pickExperience: 'Experiences · {{count}}',
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.',
@@ -143,7 +143,10 @@ export const heartbeat = {
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
},
// Picker buttons name what the list holds at the current level.
childLevels: { project: '项目', feature: '功能', thread: '子线索', cross: '跨项目故事', mixed: '项目与跨项目故事' },
unassigned: '当前无故事归属', unknownProject: '项目范围不可用',
membershipNote: '本次回顾事件按当前故事归属分组;未保存当时的故事归属。',
pickEvents: '事件 · {{count}}',
childLevels: { project: '项目', feature: '功能', thread: '子线索', cross: '跨项目故事', unassigned: '未归属事件', mixed: '分组' },
pickChildren: '{{level}} · {{count}}', pickExperience: '经验 · {{count}}',
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。',
@@ -12,6 +12,25 @@ const story = (id: string, level: SupervisionStory['level'], days: number[], ext
})
describe('story graph 3d model', () => {
it('retains historical IDs and genuinely unassigned events without borrowing current replacements', () => {
const saved = (id: string, project_id: string | null, d: number) => ({ id, project_id, title: 'Same title', description: '', occurred_at: at(d), started_at: at(d - 0.5) })
const events = [saved('old-extraction', 'p1', 1), saved('never-assigned', 'p1', 2), saved('unknown-project', null, 3), saved('exact-member', 'p1', 4)]
const current = story('feature', 'feature', [1, 4], { events: [
{ id: 'replacement', title: 'Same title', projectId: 'p1', startedAt: at(1), endedAt: at(1), primary: true, userSet: false },
{ id: 'exact-member', title: 'Current title', projectId: 'p1', startedAt: at(9), endedAt: at(9), primary: true, userSet: false }
] })
const tree = buildStoryTree([current], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(tree.events.map(event => event.id)).toEqual(events.map(event => event.id))
expect(tree.children[0]!.children.map(node => [node.level, node.events.map(event => event.id)])).toEqual([
['feature', ['exact-member']], ['unassigned', ['old-extraction', 'never-assigned']]
])
expect(tree.children[1]!.name).toBe('Unknown')
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)) })
const beforeStories = buildStoryTree([], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(beforeStories.events).toEqual(tree.events)
expect(beforeStories.children.flatMap(project => project.children).every(node => node.level === 'unassigned')).toBe(true)
})
it('builds project › feature › thread with cross stories at the root and spans from primary events', () => {
const tree = buildStoryTree([story('feature', 'feature', [3, 1]), story('thread', 'thread', [5], { parentId: 'feature' }),
story('empty', 'feature', []), story('cross', 'cross', [2, 8])], new Map())
+33 -5
View File
@@ -1,4 +1,4 @@
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
/*
@@ -10,7 +10,7 @@ import type { SupervisionExperience, SupervisionStory } from '../../shared/super
export type StoryNode = {
id: string
name: string
level: 'root' | 'project' | 'feature' | 'thread' | 'cross'
level: 'root' | 'project' | 'feature' | 'thread' | 'cross' | 'unassigned'
parent?: StoryNode
children: StoryNode[]
/** Primary events of this node and its descendants; for cross stories, the events they link. */
@@ -28,11 +28,18 @@ const TAU = Math.PI * 2
export const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]
const TARGET_TURNS = 8, MAX_TURNS = 14, TRANSITION = 0.3
/** Project › feature › thread, plus cross stories as root-level staves. Unassigned events are not staves. */
export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<string, string>): StoryNode {
/** Current memberships of exact review IDs; missing memberships never imply replacement events. */
export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<string, string>, review?: {
events: SupervisionGraphView['events']; unassigned: string; unknownProject: string
}): StoryNode {
const events = review && new Map(review.events.map(event => [event.id, event]))
if (events) stories = stories.map(story => ({ ...story, events: story.events.filter(event => events.has(event.id)) }))
const root: StoryNode = { id: 'root', name: '', level: 'root', children: [], events: [], start: 0, end: 0, a0: 0, a1: TAU, concluded: false }
const node = (story: SupervisionStory, parent: StoryNode): StoryNode => ({ id: story.id, name: story.name, level: story.level, parent, children: [],
events: story.events.filter(event => story.level === 'cross' || event.primary).map(event => ({ id: event.id, title: event.title, t: Date.parse(event.startedAt) })),
events: story.events.filter(event => story.level === 'cross' || event.primary).map(event => {
const saved = events?.get(event.id)
return { id: event.id, title: saved?.title ?? event.title, t: Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt) }
}),
start: 0, end: 0, a0: 0, a1: 0, concluded: story.state === 'concluded' })
const projects = new Map<string, StoryNode>()
for (const story of stories.filter(item => item.level === 'feature' && item.projectId)) {
@@ -48,6 +55,27 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
project.children.push(feature)
}
root.children.push(...stories.filter(item => item.level === 'cross').map(story => node(story, root)))
if (review) {
const assigned = new Set(stories.filter(story => story.level !== 'cross').flatMap(story => story.events.filter(event => event.primary).map(event => event.id)))
for (const event of review.events) {
if (assigned.has(event.id)) continue
const projectId = event.project_id ?? ''
let project = projects.get(projectId)
if (!project) {
project = { id: `project:${projectId}`, name: projectNames.get(projectId) ?? review.unknownProject, level: 'project',
parent: root, children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
projects.set(projectId, project)
root.children.push(project)
}
let unassigned = project.children.find(child => child.level === 'unassigned')
if (!unassigned) {
unassigned = { id: `unassigned:${projectId}`, name: review.unassigned, level: 'unassigned', parent: project,
children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
project.children.push(unassigned)
}
unassigned.events.push({ id: event.id, title: event.title, t: Date.parse(event.started_at ?? event.occurred_at) })
}
}
finish(root)
layout(root, 0, TAU)
return root
+18 -7
View File
@@ -367,11 +367,17 @@
width: 100%;
min-width: 0;
}
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation {
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation {
margin-top: 0;
}
.supervisor-workspace[data-view="graph"] > .supervisor-workspace__result-navigation label {
flex: 0 1 480px;
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation label {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation select {
flex: 1;
}
.supervisor-workspace__recap li {
margin: var(--space-2) 0;
@@ -440,9 +446,6 @@
min-height: 0;
aspect-ratio: auto;
}
.supervisor-workspace__graph-canvas .story-graph-3d {
padding: 0 var(--space-4) var(--space-4);
}
.supervisor-workspace__graph-list {
contain: none;
height: 100%;
@@ -1211,6 +1214,7 @@
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-4);
}
.story-graph-3d__toolbar {
display: flex;
@@ -1223,7 +1227,14 @@
.story-graph-3d__toolbar > div {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
gap: var(--space-2);
min-width: 0;
}
.story-graph-3d__views {
margin-inline-start: auto;
align-items: center;
justify-content: flex-end;
max-width: 100%;
}
.story-graph-3d__viewport {
position: relative;
+27
View File
@@ -7,11 +7,34 @@ import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { AssistantDatabase } from '../src/main/assistant/assistant-database'
import { SupervisionStoryStore } from '../src/main/assistant/supervision-stories'
import { SupervisionExperienceStore } from '../src/main/assistant/supervision-experiences'
it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
? 'renders the production supervision card at narrow widths in both themes'
: 'renders the production supervisor at desktop, narrow, and mobile widths', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervisor-'))
// Optional local acceptance run against an online backup. Never initialize/migrate or run models.
let portable: string | undefined
if (process.env.GOODBUDDY_SUPERVISOR_BACKUP) {
const connection = new DatabaseSync(process.env.GOODBUDDY_SUPERVISOR_BACKUP, { readOnly: true })
try {
const database = new AssistantDatabase(process.env.GOODBUDDY_SUPERVISOR_BACKUP)
Reflect.set(database, 'database', connection)
const results = database.listSupervisionResults()
portable = JSON.stringify({ results,
projects: connection.prepare('SELECT id, name FROM projects').all(),
graphs: Object.fromEntries(results.map(result => [result.id, database.getSupervisionGraph({ resultId: result.id })])),
stories: Object.fromEntries(results.map(result => [JSON.stringify(result.scope), {
stories: new SupervisionStoryStore(connection).list(result.scope),
experiences: new SupervisionExperienceStore(connection).list(result.scope.kind === 'projects' ? result.scope.projectIds : undefined),
unassigned: new SupervisionStoryStore(connection).unassignedCount(result.scope), canUndo: false
}]))
})
} finally { connection.close() }
}
const css = await readFile(
'src/renderer/src/supervisor-workspace.css',
'utf8'
@@ -25,6 +48,10 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
{
name: 'supervisor-fixture',
configureServer(server) {
if (portable) server.middlewares.use('/portable-review.json', (_request, response) => {
response.setHeader('Content-Type', 'application/json')
response.end(portable)
})
server.middlewares.use(
'/supervisor.html',
async (_request, response) => {
@@ -61,6 +61,77 @@ app
await settle()
}
const reports = []
if (process.env.GOODBUDDY_SUPERVISOR_BACKUP) {
const resultId = process.env.GOODBUDDY_SUPERVISOR_RESULT
assert(resultId, 'Specify the historical result to measure')
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&portable=1')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector("#supervisor-tab-graph").click()')
await js(`(() => { const e = document.querySelector('.supervisor-workspace__result-navigation select'); e.value = ${JSON.stringify(resultId)}; e.dispatchEvent(new Event('change', {bubbles:true})); })()`)
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && document.querySelectorAll(".supervisor-workspace__list-panel > button").length === 21')
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21')
await js('document.fonts.ready')
await settle()
const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
const expected = await js(`fetch('/portable-review.json').then(r=>r.json()).then(data=>data.graphs[${JSON.stringify(resultId)}].events.map(e=>e.id))`)
assert.deepEqual([...geometry.events].sort(), [...expected].sort())
assert.deepEqual([...geometry.dots].sort(), [...expected].sort(), 'Real event clusters on the helix, not just empty staves')
assert.equal(geometry.points.length, 1401)
assert(geometry.clusters > 1 && geometry.clusters <= 21, 'Events occupy multiple real time positions after layout')
const radii = geometry.points.map(point => point[0])
assert(Math.max(...radii) - Math.min(...radii) > 40)
for (const [width, theme] of [[1440, 'light'], [390, 'dark']]) {
win.setContentSize(width, 1000)
await js(`document.documentElement.dataset.theme = '${theme}'`)
await settle()
const boxes = await js(`(() => {
const box = e => { const r=e.getBoundingClientRect(); return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height}; };
const column=document.querySelector('.supervisor-workspace__graph-canvas');
const selector=column.querySelector('.supervisor-workspace__result-navigation select');
const toolbar=document.querySelector('.story-graph-3d__toolbar');
const views=toolbar.querySelector('.story-graph-3d__views');
const segment=views.querySelector('.segmented-control');
const buttons=[...segment.querySelectorAll('button')].map(box);
return {width:innerWidth,pageWidth:document.documentElement.scrollWidth,column:box(column),selector:box(selector),
heading:box(column.querySelector('.supervisor-workspace__canvas-heading')),toolbar:box(toolbar),views:box(views),
canvas:box(document.querySelector('.story-graph-3d__gl')),gaps:buttons.slice(1).map((b,i)=>b.left-buttons[i].right),
segmentPadding:getComputedStyle(segment).paddingTop,controlGap:getComputedStyle(views).gap,
events:document.querySelectorAll('.supervisor-workspace__list-panel > button').length,
bounds:JSON.parse(document.documentElement.dataset.sceneBounds),
scene:JSON.parse(document.documentElement.dataset.helix).dots.length,
clusters:JSON.parse(document.documentElement.dataset.helix).clusters};
})()`)
assert(boxes.selector.left >= boxes.column.left + 16 && boxes.selector.right <= boxes.column.right - 16, 'Selector inside central-column padding')
assert(boxes.selector.top >= boxes.column.top && boxes.selector.bottom <= boxes.heading.bottom, 'History inside heading, not above three columns')
assert.equal(Math.round(boxes.toolbar.top - boxes.heading.bottom), 16, 'Toolbar top inset')
assert(boxes.gaps.every(gap => gap >= 2), 'Shared camera segment gaps')
assert.equal(boxes.segmentPadding, '3px')
assert.equal(boxes.controlGap, '8px')
assert(boxes.views.right <= boxes.column.right - 16 && boxes.pageWidth <= width, 'Controls fit without overflow')
assert(boxes.canvas.width > 0 && boxes.canvas.height > 0)
assert.equal(boxes.events, 21); assert.equal(boxes.scene, 21)
assert(Object.values(boxes.bounds).every(value => Math.abs(value) < 1), 'All root scene vertices fit within the camera viewport')
reports.push({ theme, ...boxes })
if (width === 1440 && !process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT) await writeFile(join(artifacts, 'portable-spiral.png'), (await win.webContents.capturePage()).toPNG())
}
await js('[...document.querySelectorAll(".story-graph-3d__picker")].find(b=>b.textContent.includes("事件")).click()')
await wait('document.querySelectorAll(".story-graph-3d__menu [role=menuitem]").length === 21')
await js('document.querySelector(".story-graph-3d__menu [role=menuitem]").click()')
await wait('document.querySelector(".supervisor-workspace__list-panel > button")?.getAttribute("aria-pressed") === "true"')
// The pre-assignment case uses the same real events with an explicitly empty membership response.
await js(`window.goodbuddy.supervision.stories = async () => ({stories:[],experiences:[],unassigned:21,canUndo:false}); document.querySelector('.supervisor-workspace__canvas-heading > .icon-button').click()`)
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21 && document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector(".story-graph-3d__picker").click()')
await wait('document.querySelectorAll(".story-graph-3d__menu [role=menuitem]").length === 1')
await js('document.querySelector(".story-graph-3d__menu [role=menuitem]").click()')
await wait('document.querySelector(".story-graph-3d__picker")?.textContent.includes("未归属事件")')
assert.equal(await js('JSON.parse(document.documentElement.dataset.helix).dots.length'), 21)
assert.deepEqual(errors, [])
await writeFile(join(artifacts, 'portable-measurements.json'), JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], errors }, null, 2))
console.log(JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], emptyMembershipEvents: 21, errors }))
win.destroy(); app.quit(); return
}
if (process.env.GOODBUDDY_SUPERVISOR_SPIRAL) {
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&recap=1')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
+43 -5
View File
@@ -1,10 +1,11 @@
// Simulated visual fixture only. No preload, user database, or model is used.
// Simulated fixture, or read-only production queries from an explicitly supplied local backup. No models.
import { createRoot } from 'react-dom/client'
import { HeartbeatCenter } from '../../src/renderer/src/HeartbeatCenter'
import { SupervisionCard } from '../../src/renderer/src/RightAssistantSidebar'
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import type { SupervisionGraphView } from '../../src/shared/supervision-contracts'
import type { AssistantProject } from '../../src/shared/assistant-contracts'
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../src/shared/application-settings-contracts'
import '../../src/renderer/src/styles.css'
import '@fontsource-variable/noto-sans-sc/wght.css'
@@ -16,8 +17,15 @@ const params = new URLSearchParams(location.search)
if (params.has('spiral')) {
const THREE = await import('three')
const add = THREE.Group.prototype.add
const clear = THREE.Group.prototype.clear
let points: number[][] = []
const events = new Set<string>()
const dots = new Set<string>()
let clusters = 0
THREE.Group.prototype.clear = function () {
events.clear(); dots.clear(); clusters = 0
return clear.call(this)
}
THREE.Group.prototype.add = function (...objects) {
for (const object of objects) {
const path = ((object as import('three').Mesh).geometry as import('three').TubeGeometry)?.parameters?.path as import('three').CatmullRomCurve3 | undefined
@@ -26,10 +34,29 @@ if (params.has('spiral')) {
events.clear()
}
for (const event of object.userData.node?.events ?? []) events.add(event.id)
if (object.userData.cluster) {
clusters++
for (const event of object.userData.cluster.events) dots.add(event.id)
}
}
document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events] })
document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events], dots: [...dots], clusters })
return add.apply(this, objects)
}
if (params.has('portable')) {
THREE.Scene.prototype.onAfterRender = function (_renderer, scene, camera) {
const point = new THREE.Vector3(), bounds = { left: 1, right: -1, top: -1, bottom: 1 }
scene.traverse(object => {
const positions = (object as import('three').Mesh).geometry?.getAttribute('position')
if (!positions) return
for (let i = 0; i < positions.count; i++) {
point.fromBufferAttribute(positions, i).applyMatrix4(object.matrixWorld).project(camera)
bounds.left = Math.min(bounds.left, point.x); bounds.right = Math.max(bounds.right, point.x)
bounds.top = Math.max(bounds.top, point.y); bounds.bottom = Math.min(bounds.bottom, point.y)
}
})
document.documentElement.dataset.sceneBounds = JSON.stringify(bounds)
}
}
}
const stories = [
{
@@ -256,6 +283,17 @@ Object.defineProperty(window, 'goodbuddy', {
}
}
})
let portableProjects: AssistantProject[] = []
if (params.has('portable')) {
const data = await (await fetch('/portable-review.json')).json()
portableProjects = data.projects
Object.assign(window.goodbuddy.supervision, {
overview: async () => data.results,
graph: async ({ resultId }: { resultId: string }) => data.graphs[resultId],
stories: async ({ scope }: { scope: unknown }) => data.stories[JSON.stringify(scope)],
run: async () => { throw new Error('Read-only acceptance must not run reviews') }
})
}
const noop = async () => {}
createRoot(document.getElementById('root')!).render(
<UiLocaleProvider initialPreference="zh-CN">
@@ -277,8 +315,8 @@ createRoot(document.getElementById('root')!).render(
gap: 'var(--space-3)'
}}
>
<span>SIMULATED FIXTURE / 单故事 API / {story.title}</span>
{stories.map((item, index) => (
<span>{params.has('portable') ? 'READ-ONLY PORTABLE BACKUP / 只读回顾验证' : `SIMULATED FIXTURE / 单故事 API / ${story.title}`}</span>
{!params.has('portable') && stories.map((item, index) => (
<a key={item.title} href={`?story=${index}`}>
{item.title}
</a>
@@ -302,7 +340,7 @@ createRoot(document.getElementById('root')!).render(
runs={menu ? [{ id: 'run', configId: 'plan', trigger: 'scheduled', scheduledFor: createdAt, status: 'completed', attemptCount: 1, createdAt, updatedAt: createdAt }] : []}
entries={menu ? [{ id: 'report', configId: 'plan', runId: 'run', scheduledFor: createdAt, summary: '模拟历史心跳报告:交付计划已更新,待核对负责人和验收日期。', highlights: ['保留原始依据,核对交付时间。'], proposedMemoryIds: ['memory'], followUpTaskIds: ['task'], createdAt }] : []}
memories={menu ? [{ id: 'memory', scope: 'global', type: 'preference', content: '模拟建议:周会总结保留负责人和下一次检查日期。', confidence: 0.9, salience: 0.8, status: 'proposed', createdAt, updatedAt: createdAt }] : []}
projects={[]}
projects={portableProjects}
tasks={menu ? [{ id: 'task', title: '模拟行动:核对交付清单', instructions: '核对负责人、验收日期和原始讨论依据。', origin: 'assistant', status: 'paused', createdAt }] : []}
onCreate={noop}
onUpdate={noop}