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:
@@ -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"')
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user