feat: shared supervisor timeline, story assignment and time-spiral view
Each source version is extracted once for every review scope; events carry spans and projects from their sources, and attention density is counted from messages. After publication, events are assigned to project features and sub-threads (cross-project stories only when enabled), with user rename, merge, remove, move and undo. The story graph gains a lazily loaded Three.js time-spiral view. Includes the 3D concept demo, real-data DeepSeek check scripts and docs.
This commit is contained in:
@@ -776,7 +776,10 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
knowledgeCommit: vi.fn(async () => ({})),
|
||||
suggestions: vi.fn(async () => []),
|
||||
suggestionAction: vi.fn(async () => { throw new Error('unused') }),
|
||||
retrySuggestions: vi.fn(async () => 0)
|
||||
retrySuggestions: vi.fn(async () => 0),
|
||||
stories: vi.fn(async () => ({ stories: [], unassigned: 0, canUndo: false })),
|
||||
storyAction: vi.fn(async () => undefined),
|
||||
retryStories: vi.fn(async () => { throw new Error('unused') })
|
||||
},
|
||||
experts: {
|
||||
list: vi.fn(async () => []),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as THREE from 'three'
|
||||
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
|
||||
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
|
||||
import { buildStoryTree, clusterEvents, findNode, radiusLevels, storyWindow, visibleLevel, type StoryCluster, type StoryNode } from './story-graph-3d-model'
|
||||
|
||||
/*
|
||||
* 3D story view: one helix of time inside, story staves on the outer cylinder.
|
||||
* Geometry rebuilds only when what is shown changes; camera moves only re-render.
|
||||
* Loaded lazily so Three.js is not part of the workspace bundle.
|
||||
*/
|
||||
type Props = {
|
||||
stories: SupervisionStory[]
|
||||
attention: SupervisionAttentionSlot[]
|
||||
selectedEventId?: string
|
||||
onSelectEvent: (id: string) => void
|
||||
onSelectStory: (id: string) => void
|
||||
}
|
||||
const TAU = Math.PI * 2, HEIGHT = 430, STAVE_R = 188, DIST = 900, CLUSTER_PX = 9
|
||||
const VIEWS = { oblique: { yaw: -0.4, tilt: 0.38 }, side: { yaw: -0.4, tilt: 0 }, top: { yaw: -0.4, tilt: Math.PI / 2 - 1e-3 } } as const
|
||||
type ViewName = keyof typeof VIEWS | 'free'
|
||||
// Existing graph tokens, so light and dark themes keep the same story colours as the flat view.
|
||||
const toneVariables = ['--graph-node-1-border', '--graph-node-2-border', '--graph-node-3-border', '--graph-node-4-border', '--accent']
|
||||
|
||||
function cssColor(element: Element, variable: string, fallback: string): string {
|
||||
return getComputedStyle(element).getPropertyValue(variable).trim() || fallback
|
||||
}
|
||||
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 }: Props) {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null)
|
||||
const [supported] = useState(webglAvailable)
|
||||
const tree = useMemo(() => buildStoryTree(stories, new Map()), [stories])
|
||||
const [focusId, setFocusId] = useState('root')
|
||||
const focus = findNode(tree, focusId) ?? tree
|
||||
const [view, setView] = useState<ViewName>('oblique')
|
||||
const [hovered, setHovered] = useState<string>()
|
||||
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: { focus: StoryNode; hovered?: string; selected?: string }) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster } | undefined }>(undefined)
|
||||
const range = useMemo(() => {
|
||||
const times = tree.events.map(event => event.t)
|
||||
return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined
|
||||
}, [tree])
|
||||
const level = visibleLevel(focus)
|
||||
const trail: StoryNode[] = []
|
||||
for (let node: StoryNode | undefined = focus; node; node = node.parent) trail.unshift(node)
|
||||
const dateText = (value: number) => new Date(value).toLocaleString(i18n.resolvedLanguage, range && range.turnHours >= 24
|
||||
? { month: '2-digit', day: '2-digit' } : { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current, overlay = overlayRef.current
|
||||
if (!supported || !canvas || !overlay || !range) return
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
const scene = new THREE.Scene()
|
||||
const host = canvas.parentElement!
|
||||
const background = cssColor(host, '--surface-raised', '#fff')
|
||||
scene.fog = new THREE.Fog(background, 700, 1250)
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9cfc4, 2.2))
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(-300, 500, 400); scene.add(sun)
|
||||
const view3d = new THREE.PerspectiveCamera(30, 1, 10, 4000)
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const ctx = overlay.getContext('2d')!
|
||||
const muted = cssColor(host, '--text-muted', '#7d8a84')
|
||||
const tones = toneVariables.map((variable, index) => cssColor(host, variable, ['#2563eb', '#0f766e', '#7c3aed', '#b45309', '#086fc7'][index]!))
|
||||
const colorOf = (node: StoryNode) => tones[Math.abs([...node.id].reduce((hash, char) => hash * 31 + char.charCodeAt(0) | 0, 7)) % tones.length]!
|
||||
const levelAt = radiusLevels(range, attention)
|
||||
const u = (time: number) => (time - range.from) / (range.to - range.from)
|
||||
const y = (time: number) => (u(time) - 0.5) * HEIGHT
|
||||
const helix = (time: number) => { const r = 78 + 74 * levelAt(u(time)), a = u(time) * range.turns * TAU; return new THREE.Vector3(r * Math.cos(a), y(time), r * Math.sin(a)) }
|
||||
const on = (time: number, angle: number, r = STAVE_R) => new THREE.Vector3(r * Math.cos(angle), y(time), r * Math.sin(angle))
|
||||
const center = (node: StoryNode) => (node.a0 + node.a1) / 2
|
||||
let width = 0, height = 0
|
||||
const disposables: Array<{ dispose: () => void }> = []
|
||||
const own = <T extends { dispose: () => void }>(item: T) => { disposables.push(item); return item }
|
||||
const solid = (color: string, opacity = 1) => own(new THREE.MeshStandardMaterial({ color, roughness: 0.65, transparent: opacity < 1, opacity }))
|
||||
const flat = (color: string, opacity: number) => own(new THREE.MeshBasicMaterial({ color, transparent: true, opacity, side: THREE.DoubleSide, depthWrite: false }))
|
||||
const tube = (points: THREE.Vector3[], r: number, material: THREE.Material, segments = Math.max(8, points.length * 2)) =>
|
||||
new THREE.Mesh(own(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(points), segments, r, 8, false)), material)
|
||||
// Static base: the grey time helix and turn ticks.
|
||||
const base = new THREE.Group()
|
||||
base.add(tube(Array.from({ length: 1401 }, (_, i) => helix(range.from + (range.to - range.from) * i / 1400)), 1.1, solid(muted), 2800))
|
||||
const ticks = Array.from({ length: Math.floor(range.turns + 1e-6) + 1 }, (_, d) => range.from + d * range.turnHours * 3_600_000).filter(time => time < range.to)
|
||||
scene.add(base)
|
||||
// What is shown; the renderer and scene persist while focus, hover and selection change.
|
||||
const state: { focus: StoryNode; hovered?: string; selected?: string } = { focus }
|
||||
let staves = visibleLevel(focus).children
|
||||
const meshes: THREE.Mesh[] = []
|
||||
const groups = new Map<string, StoryCluster[]>()
|
||||
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)
|
||||
let builtSpan = -1
|
||||
const rebuild = () => {
|
||||
world.clear()
|
||||
meshes.length = 0
|
||||
groups.clear()
|
||||
const span = clusterSpan()
|
||||
builtSpan = span
|
||||
for (const stave of staves) {
|
||||
const emphasis = state.focus === visibleLevel(state.focus) ? (stave.id === state.hovered ? 'focus' : 'normal') : stave.id === state.focus.id ? 'focus' : 'dim'
|
||||
const color = colorOf(stave), gap = Math.min(0.014, (stave.a1 - stave.a0) * 0.08)
|
||||
const a0 = stave.a0 + gap, a1 = Math.max(a0 + 1e-3, stave.a1 - gap)
|
||||
const t0 = stave.start - (range.to - range.from) * 0.006, t1 = stave.end + (range.to - range.from) * 0.006
|
||||
const segments = Math.max(2, Math.ceil((a1 - a0) / 0.05))
|
||||
const geometry = own(new THREE.BufferGeometry()), positions: number[] = [], index: number[] = []
|
||||
for (let j = 0; j <= segments; j++) { const a = a0 + (a1 - a0) * j / segments, lo = on(t0, a), hi = on(t1, a); positions.push(lo.x, lo.y, lo.z, hi.x, hi.y, hi.z) }
|
||||
for (let j = 0; j < segments; j++) { const k = j * 2; index.push(k, k + 2, k + 1, k + 1, k + 2, k + 3) }
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(index)
|
||||
const mesh = new THREE.Mesh(geometry, flat(color, emphasis === 'focus' ? 0.3 : emphasis === 'dim' ? 0.05 : 0.15))
|
||||
mesh.userData.node = stave
|
||||
world.add(mesh); meshes.push(mesh)
|
||||
const clusters = clusterEvents(stave.events, span)
|
||||
groups.set(stave.id, clusters)
|
||||
for (const cluster of clusters) {
|
||||
const arc = Array.from({ length: 11 }, (_, i) => on(cluster.t, a0 + (a1 - a0) * i / 10, STAVE_R + 0.6))
|
||||
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
|
||||
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))
|
||||
dot.position.copy(p); dot.userData.cluster = cluster; dot.userData.node = stave
|
||||
world.add(dot); meshes.push(dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
const project = (point: THREE.Vector3) => { const v = point.clone().project(view3d); return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, behind: v.z > 1 } }
|
||||
const place = () => {
|
||||
const { yaw, tilt, zoom, pan } = camera.current, ct = Math.cos(tilt), st = Math.sin(tilt), target = new THREE.Vector3(0, pan, 0)
|
||||
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
|
||||
view3d.aspect = width / height; view3d.zoom = zoom; view3d.updateProjectionMatrix()
|
||||
}
|
||||
const draw = () => {
|
||||
if (!width || !height) return
|
||||
place()
|
||||
if (Math.abs(clusterSpan() - builtSpan) > builtSpan * 0.5) rebuild()
|
||||
renderer.render(scene, view3d)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = muted
|
||||
for (const time of ticks) { const p = project(helix(time)); if (!p.behind) ctx.fillText(dateText(time), p.x + 6, p.y + 3) }
|
||||
// Names only for staves facing the viewer, without overlaps; the focused stave always.
|
||||
const direction = view3d.position.clone().normalize(), placed: number[][] = []
|
||||
const labels = staves.map(stave => ({ stave, facing: Math.cos(center(stave)) * direction.x + Math.sin(center(stave)) * direction.z,
|
||||
focused: stave.id === state.focus.id || stave.id === state.hovered })).sort((a, b) => Number(b.focused) - Number(a.focused) || b.facing - a.facing)
|
||||
for (const { stave, facing, focused } of labels) {
|
||||
if (!focused && facing < -0.05) continue
|
||||
const p = project(on(stave.end, center(stave), STAVE_R + 2)); if (p.behind) continue
|
||||
ctx.font = `${focused ? '600 ' : ''}11px system-ui`
|
||||
const w = ctx.measureText(stave.name).width, box = [p.x - w / 2 - 4, p.y - 20, w + 8, 16]
|
||||
if (placed.some(b => box[0]! < b[0]! + b[2]! && b[0]! < box[0]! + box[2]! && box[1]! < b[1]! + b[3]! && b[1]! < box[1]! + box[3]!)) continue
|
||||
placed.push(box)
|
||||
ctx.globalAlpha = focused ? 1 : 0.55 + 0.45 * Math.max(0, facing)
|
||||
ctx.fillStyle = colorOf(stave); ctx.fillText(stave.name, p.x - w / 2, p.y - 8)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
ctx.font = '600 9px system-ui'; ctx.fillStyle = cssColor(host, '--surface-raised', '#fff')
|
||||
for (const mesh of meshes) {
|
||||
const cluster = mesh.userData.cluster as StoryCluster | undefined
|
||||
if (!cluster || cluster.events.length < 2) continue
|
||||
const p = project(mesh.position); if (p.behind) continue
|
||||
const label = String(cluster.events.length); ctx.fillText(label, p.x - ctx.measureText(label).width / 2, p.y + 3)
|
||||
}
|
||||
}
|
||||
const resize = new ResizeObserver(() => {
|
||||
const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height
|
||||
if (!width || !height) return
|
||||
renderer.setSize(width, height, false)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
overlay.width = Math.round(width * dpr); overlay.height = Math.round(height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
rebuild(); draw()
|
||||
})
|
||||
resize.observe(canvas)
|
||||
const zoom = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
setView('free')
|
||||
camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012)))
|
||||
draw()
|
||||
}
|
||||
canvas.addEventListener('wheel', zoom, { passive: false })
|
||||
engine.current = {
|
||||
draw,
|
||||
update: next => { Object.assign(state, next); staves = visibleLevel(state.focus).children; rebuild(); draw() },
|
||||
hit: (x, yPos) => {
|
||||
raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - yPos / height * 2), view3d)
|
||||
const hit = raycaster.intersectObjects(meshes, false)[0]
|
||||
return hit && { node: hit.object.userData.node as StoryNode, cluster: hit.object.userData.cluster as StoryCluster | undefined }
|
||||
},
|
||||
dispose: () => { canvas.removeEventListener('wheel', zoom); resize.disconnect(); disposables.forEach(item => item.dispose()); renderer.dispose() }
|
||||
}
|
||||
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
|
||||
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId }) }, [focus, hovered, selectedEventId, range, attention])
|
||||
|
||||
const animate = (goal: Partial<typeof camera.current>) => {
|
||||
const from = { ...camera.current }
|
||||
let started: number | undefined
|
||||
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
const step = (now: number) => {
|
||||
started ??= now
|
||||
const k = reduce ? 1 : Math.min(1, (now - started) / 480), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
|
||||
for (const key of ['yaw', 'tilt', 'zoom', 'pan'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e
|
||||
engine.current?.draw()
|
||||
if (k < 1) requestAnimationFrame(step)
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
}
|
||||
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)
|
||||
setFocusId(node.id)
|
||||
setHovered(undefined)
|
||||
}
|
||||
const drag = useRef<{ x: number; y: number; sx: number; sy: number; pan: boolean }>(undefined)
|
||||
const pointer = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
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 (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
|
||||
return <div className="story-graph-3d">
|
||||
<div className="story-graph-3d__toolbar">
|
||||
<nav aria-label={t('supervisor.graph3d.levels')}>
|
||||
{trail.map((node, index) => <span key={node.id}>
|
||||
{index > 0 && ' › '}
|
||||
{index === trail.length - 1 ? <strong>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</strong>
|
||||
: <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>
|
||||
</div>
|
||||
<div className="story-graph-3d__viewport">
|
||||
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
|
||||
onPointerDown={event => { const [x, y] = pointer(event); drag.current = { x, y, sx: x, sy: y, pan: event.button === 2 || event.shiftKey }; event.currentTarget.setPointerCapture(event.pointerId) }}
|
||||
onPointerMove={event => {
|
||||
const [x, y] = pointer(event)
|
||||
if (!drag.current) {
|
||||
const hit = engine.current?.hit(x, y)
|
||||
const id = hit?.node && level.children.includes(hit.node) ? hit.node.id : undefined
|
||||
if (id !== hovered) setHovered(id)
|
||||
event.currentTarget.style.cursor = hit ? 'pointer' : ''
|
||||
return
|
||||
}
|
||||
const dx = x - drag.current.x, dy = y - drag.current.y
|
||||
if (Math.hypot(x - drag.current.sx, y - drag.current.sy) >= 5 && view !== 'free') setView('free')
|
||||
if (drag.current.pan) camera.current.pan = Math.max(-HEIGHT / 2, Math.min(HEIGHT / 2, camera.current.pan + dy))
|
||||
else { camera.current.yaw += dx * 0.008; camera.current.tilt = Math.max(-Math.PI / 2 + 1e-3, Math.min(Math.PI / 2 - 1e-3, camera.current.tilt + dy * 0.006)) }
|
||||
drag.current.x = x; drag.current.y = y
|
||||
engine.current?.draw()
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
const [x, y] = pointer(event), start = drag.current
|
||||
drag.current = undefined
|
||||
if (!start || Math.hypot(x - start.sx, y - start.sy) >= 5) return
|
||||
const hit = engine.current?.hit(x, y)
|
||||
if (hit?.cluster) { if (hit.cluster.events.length === 1) onSelectEvent(hit.cluster.events[0]!.id); else open(hit.node!) }
|
||||
else if (hit?.node) open(hit.node)
|
||||
}}
|
||||
onPointerLeave={() => { if (!drag.current) setHovered(undefined) }}
|
||||
onContextMenu={event => event.preventDefault()}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape' && focus.parent) { setFocusId(focus.parent.id); return }
|
||||
const preset = ({ 1: 'oblique', 2: 'side', 3: 'top' } as const)[event.key as '1' | '2' | '3']
|
||||
if (preset) { choose(preset); return }
|
||||
const move: Record<string, () => void> = { ArrowLeft: () => { camera.current.yaw -= 0.12 }, ArrowRight: () => { camera.current.yaw += 0.12 },
|
||||
ArrowUp: () => { camera.current.tilt = Math.min(Math.PI / 2 - 1e-3, camera.current.tilt + 0.06) }, ArrowDown: () => { camera.current.tilt = Math.max(-Math.PI / 2 + 1e-3, camera.current.tilt - 0.06) } }
|
||||
if (!move[event.key]) return
|
||||
event.preventDefault(); setView('free'); move[event.key]!(); engine.current?.draw()
|
||||
}} />
|
||||
<canvas ref={overlayRef} className="story-graph-3d__overlay" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="supervisor-workspace__muted">{t('supervisor.graph3d.legend', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })}</p>
|
||||
{/* The same staves as a list, for keyboard and screen readers. */}
|
||||
<ul className="story-graph-3d__list" aria-label={t('supervisor.graph3d.staves')}>
|
||||
{level.children.map(node => <li key={node.id}><button type="button" className="link-button" aria-pressed={node.id === focus.id} onClick={() => open(node)}>
|
||||
{node.name} <small>{t('supervisor.stories.count', { count: node.events.length })} · {dateText(node.start)} – {dateText(node.end)}</small>
|
||||
</button></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -28,7 +28,19 @@ it('saves validated algorithm controls and retains the draft until saved setting
|
||||
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } })
|
||||
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByLabelText(/单次响应容量/), { target: { value: '2048' } })
|
||||
const cross = screen.getByRole('switch', { name: '跨项目关联' })
|
||||
expect(cross).not.toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048 } })
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20 } })
|
||||
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
|
||||
})
|
||||
|
||||
it('enables cross-project linking only when the switch is turned on', () => {
|
||||
const save = vi.fn()
|
||||
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
render(<SupervisionReviewSettings settings={settings} onSave={save} />)
|
||||
expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('switch', { name: '跨项目关联' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: expect.objectContaining({ crossProject: true }) })
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||
import { defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
import { defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
|
||||
const fields = [
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384]
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500]
|
||||
] as const
|
||||
|
||||
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
@@ -14,10 +14,12 @@ export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview,
|
||||
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024 }
|
||||
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024, crossProject: settings?.supervisionReview?.crossProject ?? false,
|
||||
storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents }
|
||||
const [saved, setSaved] = useState(current)
|
||||
const [draft, setDraft] = useState(current)
|
||||
if (fields.some(([key]) => saved[key] !== current[key])) { setSaved(current); setDraft(current) }
|
||||
if (fields.some(([key]) => saved[key] !== current[key]) || saved.crossProject !== current.crossProject) { setSaved(current); setDraft(current) }
|
||||
const unchanged = fields.every(([key]) => draft[key] === current[key]) && draft.crossProject === current.crossProject
|
||||
const valid = supervisionReviewSettingsSchema.safeParse(draft).success
|
||||
const locked = disabled || !settings || !onSave
|
||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={event => {
|
||||
@@ -31,8 +33,14 @@ export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
<input type="number" min={min} max={max} step={1} value={draft[key]} disabled={locked}
|
||||
onChange={event => setDraft({ ...draft, [key]: Number(event.target.value) })} />
|
||||
</label>)}
|
||||
<label className="toggle-row">
|
||||
<span>{t('reviewSettings.crossProject')}</span>
|
||||
<input type="checkbox" role="switch" checked={draft.crossProject} disabled={locked}
|
||||
onChange={event => setDraft({ ...draft, crossProject: event.target.checked })} />
|
||||
</label>
|
||||
<p>{t('reviewSettings.crossProjectHelp')}</p>
|
||||
<p>{t('reviewSettings.pauseHelp')}</p>
|
||||
{!valid && <p role="alert">{t('reviewSettings.invalid')}</p>}
|
||||
<button type="submit" className="primary-button" disabled={locked || !valid || fields.every(([key]) => draft[key] === current[key])}>{t('reviewSettings.save')}</button>
|
||||
<button type="submit" className="primary-button" disabled={locked || !valid || unchanged}>{t('reviewSettings.save')}</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
|
||||
const empty: SupervisionStoryView = { stories: [], unassigned: 0, canUndo: false }
|
||||
|
||||
/** Loads the stories of a scope and applies user adjustments. Late responses for an older scope are ignored. */
|
||||
export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | undefined, active: boolean, revision: unknown) {
|
||||
const api = window.goodbuddy?.supervision
|
||||
const key = scope ? JSON.stringify(scope) : ''
|
||||
const [view, setView] = useState<{ key: string; data: SupervisionStoryView }>({ key: '', data: empty })
|
||||
const [error, setError] = useState<string>()
|
||||
const [pending, setPending] = useState(false)
|
||||
const latestKey = useRef(key)
|
||||
useEffect(() => { latestKey.current = key }, [key])
|
||||
const load = useCallback(async () => {
|
||||
if (!api?.stories || !key) return
|
||||
try {
|
||||
const data = await api.stories({ scope: JSON.parse(key) as SupervisionRunRequest['scope'] })
|
||||
if (latestKey.current === key) { setView({ key, data }); setError(undefined) }
|
||||
} catch (reason) { if (latestKey.current === key) setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
}, [api, key])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const task = window.setTimeout(() => void load(), 0)
|
||||
return () => window.clearTimeout(task)
|
||||
}, [active, load, revision])
|
||||
const act = async (action: SupervisionStoryAction) => {
|
||||
if (!api?.storyAction || pending) return false
|
||||
setPending(true)
|
||||
setError(undefined)
|
||||
try { await api.storyAction(action); await load(); return true }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); return false }
|
||||
finally { setPending(false) }
|
||||
}
|
||||
return { view: view.key === key ? view.data : empty, error, pending, act, reload: load, available: typeof api?.stories === 'function' }
|
||||
}
|
||||
|
||||
type Props = {
|
||||
stories: SupervisionStory[]
|
||||
selectedId?: string
|
||||
onSelect: (id: string) => void
|
||||
date: (value: string) => string
|
||||
}
|
||||
|
||||
/** Stories grouped as project › feature › sub-thread, then cross-project stories. */
|
||||
export function SupervisionStoryList({ stories, selectedId, onSelect, date }: Props) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const features = stories.filter(story => story.level === 'feature')
|
||||
const threads = (id: string) => stories.filter(story => story.parentId === id)
|
||||
const projects = [...new Map(features.map(story => [story.projectId ?? '', story.projectName ?? t('settings.scope.unavailableProject')])).entries()]
|
||||
const cross = stories.filter(story => story.level === 'cross')
|
||||
const item = (story: SupervisionStory, nested = false) => (
|
||||
<button key={story.id} className={nested ? 'supervisor-workspace__story-thread' : undefined}
|
||||
aria-pressed={selectedId === story.id} onClick={() => onSelect(story.id)}>
|
||||
<span className="supervisor-workspace__list-copy">
|
||||
{story.name}
|
||||
<small>
|
||||
{t('supervisor.stories.count', { count: story.events.filter(event => story.level === 'cross' || event.primary).length })}
|
||||
{story.startedAt && ` · ${date(story.startedAt)}`}
|
||||
{story.state === 'concluded' && ` · ${t('supervisor.stories.concluded')}`}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
return <>
|
||||
{projects.map(([projectId, name]) => <section key={projectId} className="supervisor-workspace__story-group" aria-label={name}>
|
||||
<h3>{name}</h3>
|
||||
{features.filter(story => (story.projectId ?? '') === projectId).map(story => <div key={story.id}>
|
||||
{item(story)}
|
||||
{threads(story.id).map(thread => item(thread, true))}
|
||||
</div>)}
|
||||
</section>)}
|
||||
{cross.length > 0 && <section className="supervisor-workspace__story-group" aria-label={t('supervisor.stories.cross')}>
|
||||
<h3>{t('supervisor.stories.cross')}</h3>
|
||||
{cross.map(story => item(story))}
|
||||
</section>}
|
||||
</>
|
||||
}
|
||||
|
||||
/** Inspector for one story: span, state, events and the adjustments users can make. */
|
||||
export function SupervisionStoryDetail({ story, stories, pending, canUndo, onAct, onSelectEvent, date }: {
|
||||
story: SupervisionStory
|
||||
stories: SupervisionStory[]
|
||||
pending: boolean
|
||||
canUndo: boolean
|
||||
onAct: (action: SupervisionStoryAction) => Promise<boolean>
|
||||
onSelectEvent: (id: string) => void
|
||||
date: (value: string) => string
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const [name, setName] = useState<string>()
|
||||
const [mergeInto, setMergeInto] = useState('')
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const targets = stories.filter(other => other.id !== story.id && other.level === story.level && other.projectId === story.projectId)
|
||||
const shown = story.level === 'cross' ? story.events : story.events.filter(event => event.primary)
|
||||
return <>
|
||||
<div className="supervisor-workspace__detail-kicker">
|
||||
{t(`supervisor.stories.levels.${story.level}`)}{story.projectName && ` · ${story.projectName}`}
|
||||
</div>
|
||||
<h3>{story.name}</h3>
|
||||
{story.description && <p className="supervisor-workspace__detail-copy">{story.description}</p>}
|
||||
{story.startedAt && <p>{date(story.startedAt)} – {story.endedAt ? date(story.endedAt) : ''}</p>}
|
||||
<p>{story.state === 'concluded' ? t('supervisor.stories.concluded') : t('supervisor.stories.active')}
|
||||
{story.userEdited && ` · ${t('supervisor.stories.edited')}`}</p>
|
||||
<div className="supervisor-workspace__actions">
|
||||
<button className="secondary-button" disabled={pending} onClick={() => setName(story.name)}>{t('supervisor.stories.rename')}</button>
|
||||
<button className="danger-ghost" disabled={pending} onClick={() => setConfirmRemove(true)}>{t('supervisor.stories.remove')}</button>
|
||||
{canUndo && <button className="secondary-button" disabled={pending} onClick={() => void onAct({ action: 'undo' })}>{t('supervisor.stories.undo')}</button>}
|
||||
</div>
|
||||
{name !== undefined && <form onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void onAct({ action: 'rename', storyId: story.id, name: name.trim() }).then(done => { if (done) setName(undefined) })
|
||||
}}>
|
||||
<label>{t('supervisor.stories.name')}<input value={name} maxLength={60} onChange={event => setName(event.target.value)} /></label>
|
||||
<div className="supervisor-workspace__actions">
|
||||
<button className="secondary-button" disabled={pending || !name.trim()}>{t('supervisor.save')}</button>
|
||||
<button type="button" className="secondary-button" disabled={pending} onClick={() => setName(undefined)}>{t('supervisor.cancel')}</button>
|
||||
</div>
|
||||
</form>}
|
||||
{targets.length > 0 && <div className="supervisor-workspace__story-merge">
|
||||
<label>{t('supervisor.stories.mergeInto')}
|
||||
<select value={mergeInto} disabled={pending} onChange={event => setMergeInto(event.target.value)}>
|
||||
<option value="">{t('supervisor.stories.chooseStory')}</option>
|
||||
{targets.map(other => <option key={other.id} value={other.id}>{other.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button className="secondary-button" disabled={pending || !mergeInto}
|
||||
onClick={() => void onAct({ action: 'merge', storyId: story.id, intoId: mergeInto }).then(done => { if (done) setMergeInto('') })}>
|
||||
{t('supervisor.stories.merge')}
|
||||
</button>
|
||||
</div>}
|
||||
{confirmRemove && <div role="alert">
|
||||
<p>{t('supervisor.stories.removeHint')}</p>
|
||||
<div className="supervisor-workspace__actions">
|
||||
<button className="secondary-button" disabled={pending} onClick={() => setConfirmRemove(false)}>{t('supervisor.cancel')}</button>
|
||||
<button className="danger-solid" disabled={pending} onClick={() => void onAct({ action: 'remove', storyId: story.id })}>{t('supervisor.stories.remove')}</button>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="supervisor-workspace__related">
|
||||
<h4>{t('supervisor.stories.events', { count: shown.length })}</h4>
|
||||
{shown.map(event => <button key={event.id} className="link-button" onClick={() => onSelectEvent(event.id)}>
|
||||
{event.title}<small>{date(event.startedAt)}</small>
|
||||
</button>)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
/** Primary-story picker for one event; only stories of the event's project are offered. */
|
||||
export function SupervisionEventStory({ eventId, projectId, stories, pending, onAct }: {
|
||||
eventId: string
|
||||
projectId: string | null
|
||||
stories: SupervisionStory[]
|
||||
pending: boolean
|
||||
onAct: (action: SupervisionStoryAction) => Promise<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const current = stories.find(story => story.events.some(event => event.id === eventId && event.primary))
|
||||
const options = stories.filter(story => story.level !== 'cross' && story.projectId === (projectId ?? current?.projectId))
|
||||
if (!options.length && !current) return null
|
||||
return <label className="supervisor-workspace__event-story">
|
||||
{t('supervisor.stories.primary')}
|
||||
<select value={current?.id ?? ''} disabled={pending}
|
||||
onChange={event => void onAct({ action: 'move', eventId, storyId: event.target.value || null })}>
|
||||
<option value="">{t('supervisor.stories.none')}</option>
|
||||
{options.map(story => <option key={story.id} value={story.id}>{story.parentId ? `· ${story.name}` : story.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
@@ -90,6 +90,20 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
setRefresh(value => value + 1)
|
||||
}
|
||||
}
|
||||
// The review stays published; only story organizing is repeated.
|
||||
const retryStories = async (row: SupervisionActivity) => {
|
||||
if (actionPending.current || !row.reviewProgress) return
|
||||
actionPending.current = true
|
||||
setPending(row.id)
|
||||
setError(undefined)
|
||||
try { await api.retryStories({ runId: row.reviewProgress.runId }) }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
finally {
|
||||
actionPending.current = false
|
||||
setPending(undefined)
|
||||
setRefresh(value => value + 1)
|
||||
}
|
||||
}
|
||||
const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => {
|
||||
if (actionPending.current || !row.reviewProgress) return
|
||||
const runId = row.reviewProgress.runId
|
||||
@@ -183,6 +197,12 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<pre tabIndex={0} aria-label={t('reviewSettings.diagnostics')} style={{ userSelect: 'text' }}>{row.error}</pre>
|
||||
</details>
|
||||
</div>}
|
||||
{row.reviewProgress?.stories && <p className="supervisor-activity__suggestion" role={row.reviewProgress.stories.status === 'failed' ? 'alert' : undefined}>
|
||||
{row.reviewProgress.stories.status === 'failed' ? t('activity.storyFailed')
|
||||
: `${t('activity.storyStage')}: ${t(`activity.storyStates.${row.reviewProgress.stories.status}`, { assigned: row.reviewProgress.stories.assigned ?? 0 })}`}
|
||||
{row.reviewProgress.stories.status === 'failed' && typeof api?.retryStories === 'function' && <button type="button" className="secondary-button"
|
||||
disabled={!!pending || execution.active} onClick={() => void retryStories(row)}>{t('activity.retryStories')}</button>}
|
||||
</p>}
|
||||
{row.suggestionStatus && <p className="supervisor-activity__suggestion" role={row.suggestionStatus === 'failed' ? 'alert' : undefined}>
|
||||
{row.suggestionStatus === 'failed' ? t('activity.updatedSuggestionFailed')
|
||||
: `${t('activity.suggestionStage')}: ${t(`activity.suggestionStates.${row.suggestionStatus}`, { count: row.suggestionCount ?? 0 })}`}
|
||||
|
||||
@@ -319,6 +319,46 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(within(detail as HTMLElement).queryByText(/依据 2/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists stories by project and feature, lets users adjust them and move an event between stories', async () => {
|
||||
const story = (id: string, name: string, level: string, eventIds: string[], parentId: string | null = null) => ({
|
||||
id, projectId: level === 'cross' ? null : 'p', projectName: level === 'cross' ? null : 'goodbuddy', parentId, level, name, description: '',
|
||||
state: 'active', stateEventId: null, userEdited: false, startedAt: '2026-09-21T00:00:00.000Z', endedAt: '2026-09-21T00:00:00.000Z',
|
||||
events: eventIds.map(eventId => ({ id: eventId, title: eventId === 'event-1' ? '决定' : eventId, projectId: 'p', startedAt: '2026-09-21T00:00:00.000Z', endedAt: '2026-09-21T00:00:00.000Z', primary: level !== 'cross', userSet: false }))
|
||||
})
|
||||
const view = { stories: [story('11111111-1111-4111-8111-111111111111', '监督者', 'feature', ['event-1']),
|
||||
story('22222222-2222-4222-8222-222222222222', '回顾算法', 'thread', [], '11111111-1111-4111-8111-111111111111'),
|
||||
story('33333333-3333-4333-8333-333333333333', '笔记', 'feature', []),
|
||||
story('44444444-4444-4444-8444-444444444444', '果蝇', 'cross', ['event-1'])], unassigned: 2, canUndo: true }
|
||||
const stories = vi.fn(async () => view)
|
||||
const storyAction = vi.fn(async () => undefined)
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: vi.fn(async () => [result]), stories, storyAction,
|
||||
graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: '{"kind":"global"}' }, events: [{ id: 'event-1', title: '决定', description: 'd', occurred_at: '2026-09-21T00:00:00.000Z', project_id: 'p' }],
|
||||
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
} } as never
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '故事 4' }))
|
||||
expect(stories).toHaveBeenCalledWith({ scope: { kind: 'global' } })
|
||||
const list = screen.getByRole('complementary', { name: '图谱选择' })
|
||||
expect(within(list).getByRole('region', { name: 'goodbuddy' })).toBeInTheDocument()
|
||||
expect(within(list).getByRole('region', { name: '跨项目故事' })).toBeInTheDocument()
|
||||
expect(within(list).getByText('另有 2 个事件暂不归入任何故事。')).toBeInTheDocument()
|
||||
fireEvent.click(within(list).getByRole('button', { name: /监督者/ }))
|
||||
const inspector = screen.getByRole('complementary', { name: '详情与来源' })
|
||||
expect(within(inspector).getByRole('heading', { name: '监督者' })).toBeInTheDocument()
|
||||
fireEvent.change(within(inspector).getByRole('combobox', { name: '合并到' }), { target: { value: '33333333-3333-4333-8333-333333333333' } })
|
||||
fireEvent.click(within(inspector).getByRole('button', { name: '合并' }))
|
||||
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'merge', storyId: '11111111-1111-4111-8111-111111111111', intoId: '33333333-3333-4333-8333-333333333333' }))
|
||||
fireEvent.click(within(inspector).getByRole('button', { name: '撤销上次调整' }))
|
||||
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'undo' }))
|
||||
// Event detail: the story picker offers only the event's own project stories, not cross stories.
|
||||
fireEvent.click(within(inspector).getByRole('button', { name: /决定/ }))
|
||||
const picker = await within(inspector).findByRole('combobox', { name: '所属故事' })
|
||||
expect(within(picker).getAllByRole('option').map(option => option.textContent)).toEqual(['暂不归类', '监督者', '· 回顾算法', '笔记'])
|
||||
fireEvent.change(picker, { target: { value: '' } })
|
||||
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'move', eventId: 'event-1', storyId: null }))
|
||||
})
|
||||
|
||||
it('steps through real events in time order and supports keyboard selection in the canvas', async () => {
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: vi.fn(async () => [result]),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||
import { EmptyState, PageTabs } from './WorkspacePrimitives'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
import { SupervisionEventStory, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
type SupervisionRunRequest
|
||||
} from '../../shared/supervision-contracts'
|
||||
|
||||
type Selection = { kind: 'event' | 'entity' | 'relation'; id: string }
|
||||
// Three.js loads only when the spiral view is opened.
|
||||
const StoryGraph3D = lazy(() => import('./StoryGraph3D'))
|
||||
type Selection = { kind: 'event' | 'entity' | 'relation' | 'story'; id: string }
|
||||
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph' }
|
||||
type Props = {
|
||||
graphNavigation?: SupervisionGraphNavigation
|
||||
@@ -66,6 +69,7 @@ export function SupervisorWorkspace({
|
||||
const [errorAction, setErrorAction] = useState<'run' | 'source' | 'action'>()
|
||||
const [selection, setSelection] = useState<Selection>()
|
||||
const [listTab, setListTab] = useState<Selection['kind']>('event')
|
||||
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
|
||||
const [source, setSource] = useState<{
|
||||
id: string
|
||||
title: string
|
||||
@@ -162,6 +166,7 @@ export function SupervisorWorkspace({
|
||||
: current?.kind === 'entity'
|
||||
? parsedGraph.entities
|
||||
: parsedGraph.relations
|
||||
if (current?.kind === 'story') return current
|
||||
if (current && records.some((item) => item.id === current.id))
|
||||
return current
|
||||
const latestEvent = [...parsedGraph.events].sort(
|
||||
@@ -469,6 +474,8 @@ export function SupervisorWorkspace({
|
||||
const graphScope = graph.storyLine
|
||||
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
|
||||
: undefined
|
||||
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
|
||||
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
|
||||
const busy = !!api && (loading || pending !== undefined)
|
||||
const showRunNotice = (execution.active || running || pausedReview) && !dismissedNotice
|
||||
|
||||
@@ -716,7 +723,8 @@ export function SupervisorWorkspace({
|
||||
tabs={[
|
||||
{ id: 'event', label: `${t('supervisor.listTabs.event')} ${layout.events.length}` },
|
||||
{ id: 'entity', label: `${t('supervisor.listTabs.entity')} ${layout.entities.length}` },
|
||||
{ id: 'relation', label: `${t('supervisor.listTabs.relation')} ${graph.relations.length}` }
|
||||
{ id: 'relation', label: `${t('supervisor.listTabs.relation')} ${graph.relations.length}` },
|
||||
...(storyState.available ? [{ id: 'story' as const, label: `${t('supervisor.listTabs.story')} ${storyState.view.stories.length}` }] : [])
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
@@ -731,6 +739,14 @@ export function SupervisorWorkspace({
|
||||
(listTab === 'relation' && !graph.relations.length)) && (
|
||||
<p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>
|
||||
)}
|
||||
{listTab === 'story' && <>
|
||||
{storyState.error && <p role="alert">{storyState.error}</p>}
|
||||
{!storyState.view.stories.length && <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>}
|
||||
<SupervisionStoryList stories={storyState.view.stories} selectedId={selectedStory?.id} date={shortDate}
|
||||
onSelect={(id) => select({ kind: 'story', id })} />
|
||||
{storyState.view.unassigned > 0 && <p className="supervisor-workspace__muted">
|
||||
{t('supervisor.stories.unassigned', { count: storyState.view.unassigned })}</p>}
|
||||
</>}
|
||||
{listTab === 'event' && layout.events.map((event, index) => (
|
||||
<button
|
||||
key={event.id}
|
||||
@@ -808,7 +824,19 @@ export function SupervisorWorkspace({
|
||||
entities: layout.entities.length
|
||||
})}
|
||||
</span>
|
||||
{storyState.available && <div role="group" aria-label={t('supervisor.graph3d.mode')} className="supervisor-workspace__graph-mode">
|
||||
{(['flat', 'spiral'] as const).map((mode) => <button key={mode} type="button" className="secondary-button"
|
||||
aria-pressed={graphMode === mode} onClick={() => setGraphMode(mode)}>{t(`supervisor.graph3d.modes.${mode}`)}</button>)}
|
||||
</div>}
|
||||
</div>
|
||||
{graphMode === 'spiral' ? (
|
||||
<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}
|
||||
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
|
||||
onSelectStory={(id) => select({ kind: 'story', id })} />
|
||||
</Suspense>
|
||||
) : <>
|
||||
<figure className="supervisor-workspace__figure">
|
||||
<div
|
||||
className="supervisor-workspace__map-scroll"
|
||||
@@ -1101,13 +1129,21 @@ export function SupervisorWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</section>
|
||||
<aside
|
||||
className="supervisor-workspace__detail"
|
||||
aria-label={t('supervisor.inspector')}
|
||||
>
|
||||
<h2>{t('supervisor.inspector')}</h2>
|
||||
{selection ? (
|
||||
{selection?.kind === 'story' ? (
|
||||
selectedStory ? <>
|
||||
{storyState.error && <p role="alert">{storyState.error}</p>}
|
||||
<SupervisionStoryDetail key={selectedStory.id} story={selectedStory} stories={storyState.view.stories}
|
||||
pending={storyState.pending} canUndo={storyState.view.canUndo} date={date} onAct={storyState.act}
|
||||
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }} />
|
||||
</> : <p>{t('supervisor.stories.missing')}</p>
|
||||
) : selection ? (
|
||||
<>
|
||||
<div className="supervisor-workspace__detail-kicker">
|
||||
{selectedEvent
|
||||
@@ -1130,6 +1166,9 @@ export function SupervisorWorkspace({
|
||||
{selectedEvent && (
|
||||
<time>{date(selectedEvent.occurred_at)}</time>
|
||||
)}
|
||||
{selectedEvent && storyState.available && <SupervisionEventStory eventId={selectedEvent.id}
|
||||
projectId={selectedEvent.project_id ?? null} stories={storyState.view.stories}
|
||||
pending={storyState.pending} onAct={storyState.act} />}
|
||||
<div className="supervisor-workspace__related">
|
||||
<h4>{t('supervisor.connections')}</h4>
|
||||
{layout.entities
|
||||
|
||||
@@ -5,11 +5,14 @@ export const heartbeat = {
|
||||
reviewSettings: {
|
||||
title: 'Review algorithm',
|
||||
responseKiB: 'Response capacity, KiB (default 1024)',
|
||||
storyThreadEvents: 'Minimum events before a feature splits into sub-threads (default 20)',
|
||||
save: 'Save review algorithm',
|
||||
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
|
||||
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
|
||||
batchMessages: 'Messages per batch (default 20)', executionSeconds: 'Time before pausing, seconds (default 300)',
|
||||
pauseHelp: 'Reviews run continuously until complete and save each batch. Pause and continue from Activity records.',
|
||||
crossProject: 'Link across projects',
|
||||
crossProjectHelp: 'When off, new content links only to knowledge already in the same project. When on, it can link to the same work in other projects. Applies to later reviews.',
|
||||
invalid: 'Enter whole numbers within the displayed ranges.',
|
||||
pause: 'Pause review', resume: 'Continue saved review', facts: 'Retained facts and sources',
|
||||
cancel: 'Cancel review', cancelling: 'Cancelling review…', pausing: 'Pausing review…',
|
||||
@@ -69,6 +72,8 @@ export const heartbeat = {
|
||||
triggers: { manual: 'User', scheduled: 'Schedule', heartbeat: 'Heartbeat' },
|
||||
trigger: 'Triggered by', started: 'Started', finished: 'Finished', unknownScope: 'Execution scope not recorded',
|
||||
heartbeatStage: 'Heartbeat trigger', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
|
||||
storyStage: 'Stories', storyStates: { running: 'Organizing', completed: '{{assigned}} events assigned', failed: 'Failed' }, retryStories: 'Organize stories again',
|
||||
storyFailed: 'The review is saved, but stories could not be organized. Try again, or the next review will catch up.',
|
||||
suggestionStage: 'Suggestions', suggestionStates: { running: 'Generating', completed: '{{count}} generated', skipped: 'Nothing to suggest', failed: 'Failed' },
|
||||
retrySuggestions: 'Regenerate suggestions', updatedSuggestionFailed: 'Review updated; suggestions could not be generated.',
|
||||
openReview: 'Open review', pagination: 'Activity pages', previous: 'Newer', next: 'Older', page: 'Page {{page}}'
|
||||
@@ -96,7 +101,26 @@ export const heartbeat = {
|
||||
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
|
||||
reanalyzeHint: 'All content in {{scope}} from the last {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
|
||||
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations' },
|
||||
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations', story: 'Stories' },
|
||||
graph3d: {
|
||||
mode: 'Graph view', modes: { flat: 'Flat', spiral: 'Time spiral' },
|
||||
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the list below offers the same staves.',
|
||||
view: { oblique: 'Oblique', side: 'Side', top: 'Top', free: 'Free' },
|
||||
staves: 'Staves at this level',
|
||||
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.',
|
||||
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.'
|
||||
},
|
||||
stories: {
|
||||
empty: 'No stories yet. After the next review, events are grouped into features and sub-threads under each project.',
|
||||
unassigned: '{{count}} more events are not in any story.',
|
||||
count: '{{count}} events', concluded: 'Concluded', active: 'Ongoing', edited: 'Edited by you',
|
||||
cross: 'Cross-project stories', levels: { feature: 'Feature', thread: 'Sub-thread', cross: 'Cross-project story' },
|
||||
rename: 'Rename', name: 'Name', remove: 'Remove story', undo: 'Undo last change',
|
||||
removeHint: 'Its events move to the parent feature or become unassigned. Events are not deleted, and later reviews will not recreate a story with this name.',
|
||||
mergeInto: 'Merge into', chooseStory: 'Choose a story', merge: 'Merge',
|
||||
events: 'Events ({{count}})', primary: 'Story', none: 'Not in a story', missing: 'This story was changed. Select it again.'
|
||||
},
|
||||
listEmpty: 'No records of this type in this review.',
|
||||
openConversation: 'Open conversation',
|
||||
discussion: {
|
||||
|
||||
@@ -2,11 +2,14 @@ export const heartbeat = {
|
||||
reviewSettings: {
|
||||
title: '回顾算法',
|
||||
responseKiB: '单次响应容量,KiB(默认 1024)',
|
||||
storyThreadEvents: '功能拆分子线索的最少事件数(默认 20)',
|
||||
save: '保存回顾算法',
|
||||
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
|
||||
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
|
||||
batchMessages: '每批消息数(默认 20)', executionSeconds: '单次整理时长,秒(默认 300)',
|
||||
pauseHelp: '回顾持续处理至完成,每批保存进度;可从活动记录主动暂停并继续。',
|
||||
crossProject: '跨项目关联',
|
||||
crossProjectHelp: '关闭时,新内容只关联到同一项目中已有的知识;开启后,可以关联到其他项目中的同一件事。只影响之后的回顾。',
|
||||
invalid: '请输入所示范围内的整数。',
|
||||
pause: '暂停回顾', resume: '继续已保存回顾', facts: '已保留事实与来源',
|
||||
cancel: '取消回顾', cancelling: '正在取消回顾…', pausing: '正在暂停回顾…',
|
||||
@@ -66,6 +69,8 @@ export const heartbeat = {
|
||||
triggers: { manual: '用户触发', scheduled: '定时计划', heartbeat: '心跳触发' },
|
||||
trigger: '触发方式', started: '开始时间', finished: '结束时间', unknownScope: '未保存执行范围',
|
||||
heartbeatStage: '心跳触发', supervisionStage: '监督回顾', notRecorded: '无执行记录',
|
||||
storyStage: '故事整理', storyStates: { running: '整理中', completed: '已归入 {{assigned}} 个事件', failed: '整理失败' }, retryStories: '重新整理故事',
|
||||
storyFailed: '回顾已保存,故事整理失败。可以重新整理,或等下一次回顾自动补上。',
|
||||
suggestionStage: '建议', suggestionStates: { running: '生成中', completed: '已生成 {{count}} 条', skipped: '无需建议', failed: '生成失败' },
|
||||
retrySuggestions: '重新生成建议', updatedSuggestionFailed: '回顾已更新,建议生成失败。',
|
||||
openReview: '查看回顾', pagination: '活动分页', previous: '较新记录', next: '更早记录', page: '第 {{page}} 页'
|
||||
@@ -93,7 +98,26 @@ export const heartbeat = {
|
||||
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
|
||||
reanalyzeHint: '将重新分析{{scope}}最近 {{period}}的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
|
||||
listTabs: { event: '事件', entity: '实体', relation: '关系' },
|
||||
listTabs: { event: '事件', entity: '实体', relation: '关系', story: '故事' },
|
||||
graph3d: {
|
||||
mode: '图谱视图', modes: { flat: '平铺', spiral: '时间螺旋' },
|
||||
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;下方列表提供同样的木片。',
|
||||
view: { oblique: '斜俯视', side: '侧视', top: '俯视', free: '自由' },
|
||||
staves: '当前层的木片',
|
||||
legend: '高度为时间,一圈 {{turn}}(共 {{count}} 圈);半径为当时的对话密度,只表示投入集中程度。木片上下跨度为故事的起止时间。',
|
||||
turns: { 3: '3 小时', 6: '6 小时', 12: '12 小时', 24: '1 天', 168: '1 周', 720: '1 个月', 2160: '1 个季度', 8760: '1 年' },
|
||||
unsupported: '当前设备不支持三维显示,请使用平铺视图和左侧故事列表。'
|
||||
},
|
||||
stories: {
|
||||
empty: '还没有故事。下一次回顾完成后,会把事件归入项目下的功能和子线索。',
|
||||
unassigned: '另有 {{count}} 个事件暂不归入任何故事。',
|
||||
count: '{{count}} 个事件', concluded: '已结束', active: '进行中', edited: '已手动调整',
|
||||
cross: '跨项目故事', levels: { feature: '功能', thread: '子线索', cross: '跨项目故事' },
|
||||
rename: '重命名', name: '名称', remove: '移除故事', undo: '撤销上次调整',
|
||||
removeHint: '移除后,这个故事里的事件会回到上一级功能,或变为暂不归类;事件本身不会删除,之后的回顾也不会再建同名故事。',
|
||||
mergeInto: '合并到', chooseStory: '选择故事', merge: '合并',
|
||||
events: '事件({{count}})', primary: '所属故事', none: '暂不归类', missing: '这个故事已被调整,请重新选择。'
|
||||
},
|
||||
listEmpty: '本次回顾暂无此类记录。',
|
||||
openConversation: '打开会话',
|
||||
discussion: {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
|
||||
import { buildStoryTree, clusterEvents, radiusLevels, storyWindow, visibleLevel } from './story-graph-3d-model'
|
||||
|
||||
const day = 86_400_000
|
||||
const at = (d: number) => new Date(Date.parse('2026-09-01T00:00:00Z') + d * day).toISOString()
|
||||
const story = (id: string, level: SupervisionStory['level'], days: number[], extra: Partial<SupervisionStory> = {}): SupervisionStory => ({
|
||||
id, projectId: level === 'cross' ? null : 'p1', projectName: level === 'cross' ? null : 'goodbuddy', parentId: null, level, name: id, description: '',
|
||||
state: 'active', stateEventId: null, userEdited: false, startedAt: at(days[0] ?? 0), endedAt: at(days.at(-1) ?? 0),
|
||||
events: days.map((d, i) => ({ id: `${id}-${i}`, title: `${id} ${i}`, projectId: 'p1', startedAt: at(d), endedAt: at(d), primary: level !== 'cross', userSet: false })),
|
||||
...extra
|
||||
})
|
||||
|
||||
describe('story graph 3d model', () => {
|
||||
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())
|
||||
expect(tree.children.map(node => [node.level, node.name])).toEqual([['project', 'goodbuddy'], ['cross', 'cross']])
|
||||
const feature = tree.children[0]!.children[0]!
|
||||
expect(feature.events.map(event => event.id)).toEqual(['feature-1', 'feature-0', 'thread-0'])
|
||||
expect([feature.start, feature.end]).toEqual([Date.parse(at(1)), Date.parse(at(5))])
|
||||
expect(tree.children[0]!.children).toHaveLength(1)
|
||||
// Every level fills a full circle without overlap.
|
||||
const kids = tree.children
|
||||
expect(kids[0]!.a0).toBe(0)
|
||||
expect(kids.at(-1)!.a1).toBeCloseTo(Math.PI * 2)
|
||||
expect(visibleLevel(feature.children[0]!)).toBe(feature)
|
||||
})
|
||||
|
||||
it('chooses a turn length that keeps about eight and at most fourteen turns', () => {
|
||||
expect(storyWindow(0, 7 * day).turnHours).toBe(24)
|
||||
expect(storyWindow(0, day).turnHours).toBe(3)
|
||||
expect(storyWindow(0, 13 * day).turnHours).toBe(24)
|
||||
expect(storyWindow(0, 90 * day).turnHours).toBe(168)
|
||||
expect(storyWindow(0, 365 * day).turnHours).toBe(2160)
|
||||
const years = storyWindow(0, 3 * 365 * day)
|
||||
expect(years.turns).toBeLessThanOrEqual(14)
|
||||
expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6)
|
||||
})
|
||||
|
||||
it('keeps each turn round: one radius per turn, bounded change between turns', () => {
|
||||
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)
|
||||
const turn = (d: number) => Array.from({ length: 21 }, (_, i) => level((d + 0.2 + i * 0.03) / window.turns))
|
||||
expect(new Set(turn(0)).size).toBe(1)
|
||||
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)
|
||||
})
|
||||
|
||||
it('merges events closer than the span and keeps the rest apart', () => {
|
||||
const events = [0, 1, 2, 50].map(t => ({ id: String(t), title: '', t }))
|
||||
expect(clusterEvents(events, 5).map(cluster => cluster.events.length)).toEqual([3, 1])
|
||||
expect(clusterEvents(events, 0).map(cluster => cluster.events.length)).toEqual([1, 1, 1, 1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { SupervisionAttentionSlot } from '../../shared/supervision-contracts'
|
||||
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
|
||||
|
||||
/*
|
||||
* Pure model for the 3D story view (storyline model step 3), shared by the renderer and tests.
|
||||
* Height is time at a uniform scale; the helix radius is attention density, one radius per turn;
|
||||
* stories are staves on a fixed outer cylinder at stable angles. See docs/features/story-graph/3d-concept.md.
|
||||
*/
|
||||
|
||||
export type StoryNode = {
|
||||
id: string
|
||||
name: string
|
||||
level: 'root' | 'project' | 'feature' | 'thread' | 'cross'
|
||||
parent?: StoryNode
|
||||
children: StoryNode[]
|
||||
/** Primary events of this node and its descendants; for cross stories, the events they link. */
|
||||
events: StoryEventPoint[]
|
||||
start: number
|
||||
end: number
|
||||
a0: number
|
||||
a1: number
|
||||
concluded: boolean
|
||||
}
|
||||
export type StoryEventPoint = { id: string; title: string; t: number }
|
||||
export type StoryWindow = { from: number; to: number; turnHours: number; turns: number }
|
||||
|
||||
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
|
||||
|
||||
/** 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 {
|
||||
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) })),
|
||||
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)) {
|
||||
let project = projects.get(story.projectId!)
|
||||
if (!project) {
|
||||
project = { id: `project:${story.projectId}`, name: story.projectName ?? projectNames.get(story.projectId!) ?? story.projectId!, level: 'project',
|
||||
parent: root, children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
|
||||
projects.set(story.projectId!, project)
|
||||
root.children.push(project)
|
||||
}
|
||||
const feature = node(story, project)
|
||||
feature.children = stories.filter(item => item.parentId === story.id).map(thread => node(thread, feature))
|
||||
project.children.push(feature)
|
||||
}
|
||||
root.children.push(...stories.filter(item => item.level === 'cross').map(story => node(story, root)))
|
||||
finish(root)
|
||||
layout(root, 0, TAU)
|
||||
return root
|
||||
}
|
||||
|
||||
function finish(node: StoryNode): void {
|
||||
node.children.forEach(finish)
|
||||
if (node.level !== 'cross') {
|
||||
const own = new Map(node.events.map(event => [event.id, event]))
|
||||
for (const child of node.children) if (child.level !== 'cross') for (const event of child.events) own.set(event.id, event)
|
||||
node.events = [...own.values()]
|
||||
}
|
||||
node.events.sort((a, b) => a.t - b.t || a.id.localeCompare(b.id))
|
||||
node.start = node.events[0]?.t ?? 0
|
||||
node.end = node.events.at(-1)?.t ?? 0
|
||||
node.children = node.children.filter(child => child.events.length > 0)
|
||||
}
|
||||
|
||||
/** Angles by start time; width grows with the square root of event count, so small stories stay clickable. */
|
||||
export function layout(node: StoryNode, from: number, to: number): void {
|
||||
node.a0 = from; node.a1 = to
|
||||
const kids = [...node.children].sort((a, b) => a.start - b.start || a.name.localeCompare(b.name))
|
||||
const weights = kids.map(kid => Math.max(1.2, Math.sqrt(kid.events.length)))
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
||||
let at = from
|
||||
kids.forEach((kid, index) => { const width = (to - from) * weights[index]! / total; layout(kid, at, at + width); at += width })
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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<number>(n).fill(0)
|
||||
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
|
||||
}
|
||||
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
|
||||
const ease = (a: number, b: number, k: number) => a + (b - a) * (1 - Math.cos(k * Math.PI)) / 2
|
||||
if (f < half && d > 0) return ease(level[d - 1]!, level[d]!, (f + half) / TRANSITION)
|
||||
if (f > 1 - half && d < n - 1) return ease(level[d]!, level[d + 1]!, (f - 1 + half) / TRANSITION)
|
||||
return level[d]!
|
||||
}
|
||||
}
|
||||
|
||||
export type StoryCluster = { t0: number; t1: number; t: number; events: StoryEventPoint[] }
|
||||
/** Events closer than `span` (ms) along time merge into one band; zooming in re-splits them. */
|
||||
export function clusterEvents(events: StoryEventPoint[], span: number): StoryCluster[] {
|
||||
const out: StoryCluster[] = []
|
||||
for (const event of events) {
|
||||
const last = out.at(-1)
|
||||
if (last && event.t - last.t0 <= span) { last.events.push(event); last.t1 = event.t }
|
||||
else out.push({ t0: event.t, t1: event.t, t: event.t, events: [event] })
|
||||
}
|
||||
for (const cluster of out) cluster.t = (cluster.t0 + cluster.t1) / 2
|
||||
return out
|
||||
}
|
||||
|
||||
/** The staves shown for a focus: its children, or its siblings when it is a leaf. */
|
||||
export function visibleLevel(focus: StoryNode): StoryNode {
|
||||
return focus.children.length || !focus.parent ? focus : focus.parent
|
||||
}
|
||||
|
||||
export function findNode(root: StoryNode, id: string): StoryNode | undefined {
|
||||
if (root.id === id) return root
|
||||
for (const child of root.children) { const hit = findNode(child, id); if (hit) return hit }
|
||||
return undefined
|
||||
}
|
||||
@@ -536,7 +536,8 @@
|
||||
gap: var(--space-1);
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button {
|
||||
.supervisor-workspace__list-panel > button,
|
||||
.supervisor-workspace__story-group button {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -572,10 +573,12 @@
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.supervisor-workspace__list-panel > button:hover {
|
||||
.supervisor-workspace__list-panel > button:hover,
|
||||
.supervisor-workspace__story-group button:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button[aria-pressed='true'] {
|
||||
.supervisor-workspace__list-panel > button[aria-pressed='true'],
|
||||
.supervisor-workspace__story-group button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
@@ -879,3 +882,109 @@
|
||||
.supervisor-activity .heartbeat-settings__field {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Stories: project › feature › sub-thread, then cross-project stories. */
|
||||
.supervisor-workspace__story-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__story-group > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__story-group button {
|
||||
width: 100%;
|
||||
}
|
||||
.supervisor-workspace__story-group h3 {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
.supervisor-workspace__story-group button.supervisor-workspace__story-thread {
|
||||
padding-inline-start: var(--space-5);
|
||||
}
|
||||
.supervisor-workspace__story-merge,
|
||||
.supervisor-workspace__event-story {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
.supervisor-workspace__story-merge label,
|
||||
.supervisor-workspace__event-story {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.supervisor-workspace__story-merge label {
|
||||
display: flex;
|
||||
flex: 1 1 160px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
/* 3D story view. The canvas fills the graph column; staves are repeated as a list below. */
|
||||
.supervisor-workspace__graph-mode {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.story-graph-3d {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.story-graph-3d__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.story-graph-3d__toolbar > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.story-graph-3d__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 760 / 620;
|
||||
min-height: 320px;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
overflow: hidden;
|
||||
}
|
||||
.story-graph-3d__viewport canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
.story-graph-3d__viewport canvas:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.story-graph-3d__overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
.story-graph-3d__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.story-graph-3d__list small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
Reference in New Issue
Block a user