@@ -586,26 +606,7 @@ export function SupervisorWorkspace({
}
- {results.length > 0 &&
-
- {latest && }
-
}
+ {historySelector}
{latest && recapStories.available && { select(focus); onTabChange?.('graph') }} />}
{latest ? (
@@ -653,6 +654,7 @@ 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. */}
{!loading && !loadError && !graph.events.length && (
@@ -827,7 +829,7 @@ export function SupervisorWorkspace({
}>
{t('supervisor.loading')}
}>
- { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts
index 7166c5d8..53a4d06e 100644
--- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts
+++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts
@@ -141,7 +141,7 @@ export const heartbeat = {
legendTitle: 'Legend', legendClose: 'Close legend',
legendItems: {
time: 'Height is time; {{scale}}.',
- radius: 'Spiral radius is conversation density at the time and only shows how concentrated the work was.',
+ radius: 'Radius compares user and assistant messages per hour within this review. Equal rates have equal radii; no recorded messages gives the minimum radius. It does not measure time spent or productivity.',
stave: 'A stave spans its story from first to last event.',
experience: 'Diamonds outside the barrel are experiences; arcs run from the stave they formed in to the stave that later used them.'
},
diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
index d3998d04..cd8b528d 100644
--- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
+++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
@@ -138,7 +138,7 @@ export const heartbeat = {
legendTitle: '图例', legendClose: '关闭图例',
legendItems: {
time: '高度为时间,{{scale}}。',
- radius: '螺旋半径为当时的对话密度,只表示投入集中程度。',
+ radius: '半径比较本次回顾内每小时的用户与助手消息数。密度相同则半径相同,无消息记录时取最小半径,不代表投入时长或工作效率。',
stave: '木片上下跨度为故事的起止时间。',
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
},
diff --git a/src/renderer/src/story-graph-3d-model.test.ts b/src/renderer/src/story-graph-3d-model.test.ts
index 4209c469..cf692a1e 100644
--- a/src/renderer/src/story-graph-3d-model.test.ts
+++ b/src/renderer/src/story-graph-3d-model.test.ts
@@ -38,7 +38,7 @@ describe('story graph 3d model', () => {
expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6)
})
- it('keeps each turn round: one radius per turn, bounded change between turns', () => {
+ it('keeps each turn round with continuous transitions and actual density differences', () => {
const window = storyWindow(Date.parse(at(0)), Date.parse(at(8)))
const attention = [{ start: at(0.5), turns: 100, characters: 1 }, { start: at(4.5), turns: 1, characters: 1 }]
const level = radiusLevels(window, attention)
@@ -47,7 +47,35 @@ describe('story graph 3d model', () => {
expect(level(0.5 / window.turns)).toBe(1)
let previous = level(0), largest = 0
for (let i = 1; i <= 800; i++) { const next = level(i / 800); largest = Math.max(largest, Math.abs(next - previous)); previous = next }
- expect(largest).toBeLessThan(0.05)
+ expect(largest).toBeLessThan(0.06)
+ expect(level(4.5 / window.turns)).toBeCloseTo(0.1)
+ expect(level(2.5 / window.turns)).toBe(0)
+ for (let d = 1; d < window.turns; d++) {
+ expect(level((d - 1e-8) / window.turns)).toBeCloseTo(level((d + 1e-8) / window.turns), 6)
+ }
+ })
+
+ it('normalizes hourly rates including partial turns without inventing variation for equal densities', () => {
+ const window = storyWindow(Date.parse(at(0)), Date.parse(at(0)) + 7.5 * 3_600_000, 3)
+ const attention = Array.from({ length: 8 }, (_, hour) => ({
+ start: new Date(window.from + hour * 3_600_000).toISOString(), turns: hour === 7 ? 2 : 4, characters: 0
+ }))
+ const level = radiusLevels(window, attention)
+ for (const u of [0, 0.2, 0.5, 0.8, 1]) expect(level(u)).toBeCloseTo(1)
+ expect(radiusLevels(window, [])(0.5)).toBe(0)
+ expect(storyWindow(0, 1_800_000).turns).toBeCloseTo(1 / 6)
+ })
+
+ it('retains a clipped boundary bucket and ignores attention outside the selected interval', () => {
+ const from = Date.parse(at(0)) + 50 * 60_000
+ const window = storyWindow(from, from + 6 * 3_600_000, 3)
+ const level = radiusLevels(window, [
+ { start: at(-1), turns: 10000, characters: 0 },
+ { start: at(0), turns: 4, characters: 0 },
+ { start: new Date(from + 4 * 3_600_000).toISOString(), turns: 1, characters: 0 }
+ ])
+ expect(level(0.25)).toBe(1)
+ expect(level(0.75)).toBe(0.5)
})
it('merges events closer than the span and keeps the rest apart', () => {
diff --git a/src/renderer/src/story-graph-3d-model.ts b/src/renderer/src/story-graph-3d-model.ts
index 677d6583..8a384ca7 100644
--- a/src/renderer/src/story-graph-3d-model.ts
+++ b/src/renderer/src/story-graph-3d-model.ts
@@ -26,7 +26,7 @@ export type StoryWindow = { from: number; to: number; turnHours: number; turns:
const TAU = Math.PI * 2
export const TURN_STEPS = [3, 6, 12, 24, 168, 720, 2160, 8760]
-const TARGET_TURNS = 8, MAX_TURNS = 14, MAX_STEP = 0.22, TRANSITION = 0.3
+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): StoryNode {
@@ -78,32 +78,38 @@ export function layout(node: StoryNode, from: number, to: number): void {
/** Turn length keeps about 8 turns, at most 14, for any span from hours to years. */
export function storyWindow(from: number, to: number, turnHours?: number): StoryWindow {
- const span = Math.max(1, (to - from) / 3_600_000)
+ const span = Math.max(1 / 3_600_000, (to - from) / 3_600_000)
// Closest to the target among steps that stay within the maximum (13 days → 1 day per turn, not 1.9 weekly turns).
const fitting = TURN_STEPS.filter(step => span / step <= MAX_TURNS)
let hours = turnHours ?? fitting.reduce((best, step) => Math.abs(span / step - TARGET_TURNS) < Math.abs(span / best - TARGET_TURNS) ? step : best, fitting[0] ?? TURN_STEPS.at(-1)!)
if (span / hours > MAX_TURNS) hours = fitting[0] ?? TURN_STEPS.at(-1)!
- return { from, to: Math.max(to, from + 1), turnHours: hours, turns: Math.max(0.5, span / hours) }
+ return { from, to: Math.max(to, from + 1), turnHours: hours, turns: span / hours }
}
/**
* Radius level 0..1 at position u (0..1) along the window. Each turn holds one radius from its
- * attention, so it reads as a circle; neighbouring turns differ by at most MAX_STEP and ease at the boundary.
+ * messages per hour, so it reads as a circle; neighbouring turns ease at the boundary.
+ * Hourly buckets are distributed by overlap; their within-hour timing is unavailable.
*/
export function radiusLevels(window: StoryWindow, attention: SupervisionAttentionSlot[]): (u: number) => number {
const n = Math.max(1, Math.ceil(window.turns - 1e-6))
const perTurn = new Array(n).fill(0)
+ const duration = (window.to - window.from) / window.turns
for (const slot of attention) {
- const t = Date.parse(slot.start) + 1_800_000
- if (t < window.from || t > window.to) continue
- perTurn[Math.min(n - 1, Math.floor((t - window.from) / (window.to - window.from) * window.turns))]! += slot.turns
+ // The review query includes its upper endpoint, including an exact hourly boundary.
+ if (Date.parse(slot.start) === window.to) { perTurn[n - 1]! += slot.turns; continue }
+ const start = Math.max(window.from, Date.parse(slot.start))
+ const end = Math.min(window.to, Date.parse(slot.start) + 3_600_000)
+ if (end <= start) continue
+ for (let d = 0; d < n; d++) {
+ const overlap = Math.max(0, Math.min(end, window.from + (d + 1) * duration) - Math.max(start, window.from + d * duration))
+ perTurn[d]! += slot.turns * overlap / (end - start)
+ }
}
+ // Normalize rates, not totals: the last partial turn has a shorter exposure.
+ for (let d = 0; d < n; d++) perTurn[d]! /= Math.min(duration, window.to - window.from - d * duration) / 3_600_000
const top = Math.max(1e-6, ...perTurn)
const level = perTurn.map(value => Math.sqrt(value / top))
- for (let pass = 0; pass < n; pass++) for (let d = 1; d < n; d++) {
- if (level[d]! - level[d - 1]! > MAX_STEP) level[d - 1] = level[d]! - MAX_STEP
- if (level[d - 1]! - level[d]! > MAX_STEP) level[d] = level[d - 1]! - MAX_STEP
- }
return (u: number) => {
const x = Math.max(0, Math.min(1, u)) * window.turns
const d = Math.min(n - 1, Math.floor(x)), f = x - d, half = TRANSITION / 2
@@ -175,4 +181,4 @@ export function experienceLinks(experiences: SupervisionExperience[], staves: St
angle: a + delta / 2, t: (formed.t + to.t) / 2 }, weight: new Set(applied.map(item => item.stave)).size })
}
return links.sort((x, y) => y.weight - x.weight || x.link.from.t - y.link.from.t).slice(0, limit).map(item => item.link)
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/supervisor-workspace.css b/src/renderer/src/supervisor-workspace.css
index 56b5a888..e6bd925a 100644
--- a/src/renderer/src/supervisor-workspace.css
+++ b/src/renderer/src/supervisor-workspace.css
@@ -367,6 +367,12 @@
width: 100%;
min-width: 0;
}
+.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__recap li {
margin: var(--space-2) 0;
overflow-wrap: anywhere;
diff --git a/tests/support/supervisor-layout-driver.mjs b/tests/support/supervisor-layout-driver.mjs
index 7a0dfedf..c00f18c8 100644
--- a/tests/support/supervisor-layout-driver.mjs
+++ b/tests/support/supervisor-layout-driver.mjs
@@ -61,6 +61,41 @@ app
await settle()
}
const reports = []
+ 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"')
+ await js('document.querySelector("#supervisor-tab-graph").click()')
+ await wait('[...document.querySelectorAll("button")].some(b => b.textContent === "时间螺旋")')
+ await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
+ await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 8')
+ const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
+ assert.equal(geometry.points.length, 1401, 'One continuous, full-span helix')
+ const radii = geometry.points.map(p => p[0])
+ assert(Math.max(...radii) - Math.min(...radii) > 40, 'Actual rendered geometry reflects uneven attention')
+ geometry.points.forEach((point, i) => assert(Math.abs(point[1] - (-215 + 430 * i / 1400)) < 1e-8, 'Height advances uniformly'))
+ assert(!geometry.events.includes('outside-review'), 'Aggregate story events outside this review are not plotted')
+ 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 selector = document.querySelector('.supervisor-workspace__result-navigation select');
+ const r = selector.getBoundingClientRect(), gl = document.querySelector('.story-graph-3d__gl').getBoundingClientRect();
+ return { value: selector.value, left: r.left, right: r.right, width: innerWidth, canvasWidth: gl.width, canvasHeight: gl.height, pageWidth: document.documentElement.scrollWidth };
+ })()`)
+ assert.equal(boxes.value, 'fixture-result')
+ assert(boxes.left >= 0 && boxes.right <= boxes.width && boxes.pageWidth <= boxes.width, 'History selector fits without horizontal overflow')
+ assert(boxes.canvasWidth > 0 && boxes.canvasHeight > 0, 'WebGL canvas is laid out')
+ }
+ await js(`(() => { const e = document.querySelector('.supervisor-workspace__result-navigation select');
+ e.value = 'older-result'; e.dispatchEvent(new Event('change', {bubbles:true})); })()`)
+ await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 1')
+ await js('document.querySelector("#supervisor-tab-overview").click()')
+ await wait('document.querySelector(".supervisor-workspace__result-navigation select")?.value === "older-result"')
+ assert.deepEqual(errors, [])
+ console.log(JSON.stringify({ spiral: 'passed', vertices: geometry.points.length, radiusRange: [Math.min(...radii), Math.max(...radii)], widths: [1440, 390], errors }))
+ win.destroy(); app.quit(); return
+ }
const checkDiscussion = async () => {
const report = await js(`(() => {
const editor = document.querySelector('.supervision-discussion-editor');
diff --git a/tests/support/supervisor-layout-regression.tsx b/tests/support/supervisor-layout-regression.tsx
index c344d993..3cfa782b 100644
--- a/tests/support/supervisor-layout-regression.tsx
+++ b/tests/support/supervisor-layout-regression.tsx
@@ -13,6 +13,24 @@ import { installBundledUiFonts } from '../../src/renderer/src/fonts'
installBundledUiFonts()
const params = new URLSearchParams(location.search)
+if (params.has('spiral')) {
+ const THREE = await import('three')
+ const add = THREE.Group.prototype.add
+ let points: number[][] = []
+ const events = new Set()
+ 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
+ if (path?.points?.length === 1401) {
+ points = path.points.map(point => [Math.hypot(point.x, point.z), point.y])
+ events.clear()
+ }
+ for (const event of object.userData.node?.events ?? []) events.add(event.id)
+ }
+ document.documentElement.dataset.helix = JSON.stringify({ points, events: [...events] })
+ return add.apply(this, objects)
+ }
+}
const stories = [
{
title: '监督者 · 故事图谱',
@@ -148,6 +166,13 @@ Object.defineProperty(window, 'goodbuddy', {
? {}
: {
supervision: {
+ ...(params.has('spiral') ? { stories: async () => ({ stories: [{
+ id: 'feature', projectId: 'p', projectName: 'Fixture project', parentId: null, level: 'feature', name: 'Fixture story',
+ description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: null, endedAt: null,
+ events: [...graph.events, { id: 'outside-review', title: 'Outside review', occurred_at: '2026-08-01T00:00:00Z' }].map(event => ({
+ id: event.id, title: event.title, startedAt: event.occurred_at, endedAt: event.occurred_at, projectId: 'p', primary: true, userSet: false
+ }))
+ }], experiences: [], unassigned: 0, canUndo: false }) } : {}),
continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }),
continue: async () => { throw new Error('Preview fixture must not send a message') },
execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }),
@@ -189,7 +214,7 @@ Object.defineProperty(window, 'goodbuddy', {
scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: params.has('recap') ? ['确认外部评审时间,并回填交付清单。', '检查验收材料是否覆盖所有已确认需求。'] : [] },
...(params.has('recap') ? [{ id: 'older-result', storyLineId: 'fixture', sourceId: 'source-1', summary: '上期结果:交付清单尚缺负责人,需要继续核对。', changeDigest: '', createdAt: '2026-09-15T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-15T00:00:00Z' }, openItems: ['核对负责人。'] }] : [])]
},
- graph: async () =>
+ graph: async (input?: { resultId?: string }) =>
state === 'empty'
? {
storyLine: null,
@@ -200,7 +225,12 @@ Object.defineProperty(window, 'goodbuddy', {
eventEntities: [],
eventSources: []
}
- : graph,
+ : params.has('spiral') ? { ...graph,
+ events: input?.resultId === 'older-result' ? graph.events.slice(0, 1) : graph.events,
+ attention: [1, 1, 1, 100, 100, 4, 4].map((turns, index) => ({
+ start: `2026-09-${String(1 + index * 3).padStart(2, '0')}T01:00:00.000Z`, turns, characters: turns * 20
+ })).filter(slot => input?.resultId !== 'older-result' || slot.start < '2026-09-15')
+ } : graph,
run: async () => {
if (params.has('recap') && !params.has('fail-run')) return new Promise(() => {})
throw new Error('SIMULATED: 模型暂时不可用。此验证不调用模型。')