fix: unify supervisor review layouts and story spiral colors

Review history and context were split across recap and graph views, while the story spiral rendered saved event intervals in a single neutral color. Share the history picker and context bar, collapse new-review inputs, improve recap and responsive detail layouts, and align graph toolbars and accessible legends.

Color actual event intervals and markers by primary story, preserve neutral gaps and unassigned events, and refresh colors across themes. Include related regression coverage, layout fixtures, overlay timing and workspace-import test adjustments, and updated design and validation documentation.

Release note: Work review and story graph now share the selected review and a compact history picker. Recaps and graph details fit smaller windows, and the time spiral uses story colors for recorded event intervals without filling gaps.
This commit is contained in:
mesalogo
2026-10-04 22:50:31 +08:00
parent b9186a616c
commit ea3d607fdd
22 changed files with 819 additions and 394 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ it('imports multichunk binary and empty files into the selected directory, retai
const listing = await access.listDirectory({ path: 'selected' })
const metadata = await stat(join(root, 'selected/image.png'))
expect(listing.entries.find(entry => entry.name === 'image.png')).toMatchObject({ modifiedAt: metadata.mtime.toISOString(), ...(metadata.birthtimeMs > 0 ? { createdAt: metadata.birthtime.toISOString() } : {}) })
})
}, 15_000)
it('removes a partial import after a write failure and continues the batch', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-import-failure-')); roots.push(root)
+34
View File
@@ -0,0 +1,34 @@
import { useRef, useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { AnchoredMenu } from './AnchoredMenu'
afterEach(cleanup)
it('focuses the selected radio item, navigates enabled items and restores the anchor on Escape', () => {
function Menu() {
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
return <>
<button ref={anchorRef} onClick={() => setOpen(true)}>View</button>
{open && <AnchoredMenu anchorRef={anchorRef} id="views" label="View" onClose={() => setOpen(false)}>
<button role="menuitemradio" aria-checked={false}>Oblique</button>
<button role="menuitemradio" aria-checked={false} disabled>Side</button>
<button role="menuitemradio" aria-checked={true}>Top</button>
<button role="menuitem">Reset</button>
</AnchoredMenu>}
</>
}
render(<Menu />)
fireEvent.click(screen.getByRole('button', { name: 'View' }))
expect(screen.getByRole('menuitemradio', { name: 'Top' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
expect(screen.getByRole('menuitemradio', { name: 'Oblique' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'End' })
expect(screen.getByRole('menuitem', { name: 'Reset' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
expect(screen.getByRole('menuitemradio', { name: 'Oblique' })).toHaveFocus()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'View' })).toHaveFocus()
})
+3 -2
View File
@@ -41,7 +41,8 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
const anchor = anchorRef.current
const focusFirst = (): void => {
if (document.activeElement === document.body || document.activeElement === menu) {
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
(menu.querySelector<HTMLElement>('[role="menuitemradio"][aria-checked="true"]:not(:disabled), [role="menuitem"][aria-current="true"]:not(:disabled)')
?? menu.querySelector<HTMLElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)') ?? menu).focus()
}
}
menu.focus()
@@ -65,7 +66,7 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
}
if (role !== 'menu' || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const items = Array.from(menu.querySelectorAll<HTMLElement>(':is([role="menuitem"], [role="menuitemradio"]):not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
+2 -1
View File
@@ -14216,7 +14216,7 @@ describe("App", () => {
await waitFor(() =>
expect(screen.queryByText("监督者加载失败")).not.toBeInTheDocument(),
);
expect(screen.getByRole("button", { name: "回顾" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "新回顾" })).toBeInTheDocument();
});
it('routes Supervisor review feedback through dismissible application notifications across tabs', async () => {
@@ -14225,6 +14225,7 @@ describe("App", () => {
vi.mocked(api.supervision.run).mockImplementation(() => new Promise((_, fail) => { reject = fail; }));
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: '监督者' }));
fireEvent.click(await screen.findByRole('button', { name: '新回顾' }));
fireEvent.click(await screen.findByRole('button', { name: '回顾' }));
const started = await screen.findByText('回顾已开始,可到活动记录查看进度。');
expect(started.closest('.app-notification')).toHaveAttribute('role', 'status');
+5 -4
View File
@@ -202,9 +202,9 @@ describe('HeartbeatCenter', () => {
renderComponent(<PageShell variant="supervisor"><HeartbeatCenter {...props} /></PageShell>)
await screen.findByText('Review summary')
expect(screen.getByRole('heading', { level: 1, name: t('center.title') }).closest('.page-shell')).toHaveClass('page-shell--supervisor')
expect(screen.getByRole('heading', { name: t('supervisor.latest') })).toBeVisible()
expect(screen.getByRole('button', { name: t('supervisor.run') })).toBeEnabled()
expect(screen.getByRole('combobox', { name: t('supervisor.scope') })).toBeVisible()
expect(screen.getByRole('heading', { name: t('supervisor.summary') })).toBeVisible()
expect(screen.getByRole('button', { name: t('supervisor.newReview') })).toBeEnabled()
expect(screen.queryByRole('combobox', { name: t('supervisor.scope') })).not.toBeInTheDocument()
expect(screen.queryByText(t('supervisor.sourcesHint'))).not.toBeInTheDocument()
const pageTabKeys = ['supervisor.recap', 'supervisor.graph', 'supervisor.automatic', 'activity.title', 'supervisor.settings']
for (const key of pageTabKeys) {
@@ -262,7 +262,7 @@ describe('HeartbeatCenter', () => {
}
expect(within(panel).getByRole('region', { name: t('supervisor.canvas') })).toBeVisible()
expect(within(panel).getByRole('heading', { name: t('supervisor.inspector') })).toBeVisible()
expect(within(panel).getByText(new RegExp(`^${t('supervisor.graphScope')}: ${t('center.scope.global')} · `))).toBeVisible()
expect(within(panel).getByRole('group', { name: t('supervisor.recap') })).toHaveTextContent(t('center.scope.global'))
}
if (key === 'activity.title') {
await screen.findByText(t('activity.empty'))
@@ -709,6 +709,7 @@ describe('HeartbeatCenter', () => {
const props = createProps({ configs: [] })
renderComponent(<HeartbeatCenter {...props} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: props.projects[0]!.id } })
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
+5 -4
View File
@@ -1,12 +1,13 @@
import { CircleHelp } from 'lucide-react'
import { CircleHelp, Info } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { FloatingPortal } from './FloatingPortal'
import './inline-help.css'
export function InlineHelp({ label, children, id }: {
export function InlineHelp({ label, children, id, icon = 'help' }: {
label: string
children: ReactNode
id?: string
icon?: 'help' | 'info'
}): React.JSX.Element {
const generatedId = useId()
const contentId = id ?? generatedId
@@ -92,7 +93,7 @@ export function InlineHelp({ label, children, id }: {
}
}, [open])
return <>
<button type="button" className="inline-help" ref={anchorRef} aria-label={label}
<button type="button" className="inline-help" ref={anchorRef} aria-label={label} title={icon === 'info' ? label : undefined}
aria-expanded={open} aria-controls={open ? contentId : undefined} aria-describedby={open || id ? contentId : undefined}
onMouseEnter={enter} onMouseLeave={leave} onFocus={() => { cancelClose(); setOpen(true) }} onBlur={leave}
onClick={event => {
@@ -100,7 +101,7 @@ export function InlineHelp({ label, children, id }: {
if (pinned.current) close()
else { cancelClose(); pinned.current = true; setOpen(true) }
}}>
<CircleHelp size={14} aria-hidden="true" />
{icon === 'info' ? <Info size={14} aria-hidden="true" /> : <CircleHelp size={14} aria-hidden="true" />}
</button>
{/* Referenced hidden text remains an accessible description without a second visible copy. */}
{!open && id && <span id={contentId} hidden>{children}</span>}
+72 -58
View File
@@ -1,12 +1,12 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Info, X } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, timelineSegments, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
/*
* 3D story view: one helix of time inside, story staves on the outer cylinder.
@@ -14,6 +14,7 @@ import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels,
* Loaded lazily so Three.js is not part of the workspace bundle.
*/
type Props = {
toolbar?: ReactNode
stories: SupervisionStory[]
events: SupervisionGraphView['events']
projectNames: Map<string, string>
@@ -31,8 +32,7 @@ const TAU = Math.PI * 2, HEIGHT = 430, STAVE_R = 188, DIST = 900, CLUSTER_PX = 9
const noExperiences: SupervisionExperience[] = []
type EngineState = { focus: StoryNode; hovered?: string; selected?: string; experiences: SupervisionExperience[]; experience?: string }
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.
// Reuse the graph theme palette; story identity determines its tone across levels.
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 {
@@ -63,7 +63,7 @@ function webglAvailable(): boolean {
catch { return false }
}
export default function StoryGraph3D({ stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ toolbar, stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -74,23 +74,25 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
// Context creation failed or the GPU dropped the context; `attempt` recreates the scene on retry.
const [failure, setFailure] = useState<'create' | 'lost'>()
const [attempt, setAttempt] = useState(0)
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme)
useEffect(() => {
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
const tree = useMemo(() => buildStoryTree(stories, projectNames, { events,
unassigned: t('supervisor.graph3d.unassigned'), unknownProject: t('supervisor.graph3d.unknownProject') }), [stories, events, projectNames, t])
const [focusId, setFocusId] = useState('root')
const focus = findNode(tree, focusId) ?? tree
const [view, setView] = useState<ViewName>('oblique')
const [hovered, setHovered] = useState<string>()
const [legendOpen, setLegendOpen] = useState(false)
const legendId = useId()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 })
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
const range = useMemo(() => {
if (timeRange) return storyWindow(Date.parse(timeRange.from), Date.parse(timeRange.to))
const times = tree.events.map(event => event.t)
const times = tree.events.flatMap(event => [event.t, event.end ?? event.t])
return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined
}, [tree, timeRange])
const level = visibleLevel(focus)
const shownLinks = useMemo(() => experienceLinks(experiences, level.children), [experiences, level])
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
@@ -123,7 +125,8 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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 storyColor = (id?: string) => id ? tones[Math.abs([...id].reduce((hash, char) => hash * 31 + char.charCodeAt(0) | 0, 7)) % tones.length]! : muted
const colorOf = (node: StoryNode) => storyColor(node.level === 'unassigned' ? undefined : node.id)
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
@@ -137,9 +140,36 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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.
// One continuous tube, with material boundaries at actual event interval endpoints.
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 intervals = timelineSegments(tree.events, range)
const times = [...new Set([...Array.from({ length: 2801 }, (_, i) => range.from + (range.to - range.from) * i / 2800),
...intervals.flatMap(interval => [interval.from, interval.to])])].sort((a, b) => a - b)
class TimeCurve extends THREE.Curve<THREE.Vector3> {
constructor() { super() }
getPoint(u: number, target = new THREE.Vector3()) {
const index = Math.min(times.length - 2, Math.floor(u * (times.length - 1)))
return target.copy(helix(times[index]! + (times[index + 1]! - times[index]!) * (u * (times.length - 1) - index)))
}
// TubeGeometry normally reparameterizes by arc length, which would move time/color boundaries.
getPointAt(u: number, target?: THREE.Vector3) { return this.getPoint(u, target) }
getTangentAt(u: number, target?: THREE.Vector3) { return this.getTangent(u, target) }
}
const geometry = own(new THREE.TubeGeometry(new TimeCurve(), times.length - 1, 1.1, 8, false))
const materials = [solid(muted), ...tones.map(color => solid(color))]
geometry.clearGroups()
let intervalIndex = 0
for (let i = 0; i < times.length - 1; i++) {
while (intervalIndex < intervals.length - 1 && times[i]! >= intervals[intervalIndex]!.to) intervalIndex++
const color = storyColor(intervals[intervalIndex]?.event?.storyId)
const materialIndex = color === muted ? 0 : tones.indexOf(color) + 1
const last = geometry.groups.at(-1)
if (last?.materialIndex === materialIndex) last.count += 48
else geometry.addGroup(i * 48, 48, materialIndex)
}
const timeline = new THREE.Mesh(geometry, materials)
timeline.userData.timeline = intervals
base.add(timeline)
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.
@@ -170,15 +200,18 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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)
const owners = [...new Set(stave.events.map(event => event.storyId))]
const clusters = owners.flatMap(id => clusterEvents(stave.events.filter(event => event.storyId === id), span))
groups.set(stave.id, clusters)
for (const cluster of clusters) {
const color = storyColor(cluster.events[0]?.storyId)
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))
}
// Every event is represented at the root; connectors emphasize focus and selection only.
for (const cluster of clusters) {
const color = storyColor(cluster.events[0]?.storyId)
const p = helix(cluster.t), active = cluster.events.some(event => event.id === state.selected)
if (emphasis === 'focus' || active) world.add(tube([p, on(cluster.t, center(stave))], active ? 0.9 : 0.5, solid(color, active ? 1 : 0.6), 2))
const dot = new THREE.Mesh(own(new THREE.SphereGeometry(Math.min(6, 2.2 + Math.log2(cluster.events.length)) + (active ? 1.5 : 0), 16, 12)), solid(color, emphasis === 'dim' ? 0.3 : 1))
@@ -265,7 +298,6 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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()
}
@@ -284,10 +316,10 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss(); glCanvas.remove() }
}
return () => { engine.current?.dispose(); engine.current = undefined }
// Focus, hover and selection are pushed through update() below; only data, locale and a retry recreate the scene.
}, [supported, range, attention, i18n.resolvedLanguage, attempt]) // eslint-disable-line react-hooks/exhaustive-deps
// Focus, hover and selection use update(); data, locale, theme and retries recreate the scene.
}, [supported, range, attention, i18n.resolvedLanguage, attempt, theme]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId])
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId, theme, attempt])
const animate = (goal: Partial<typeof camera.current>) => {
const from = { ...camera.current }
@@ -302,7 +334,7 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
}
requestAnimationFrame(step)
}
const choose = (name: ViewName) => { setView(name); if (name !== 'free') animate({ ...VIEWS[name], zoom: 1, pan: 0 }) }
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0 })
const open = (node: StoryNode) => {
if (node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setFocusId(node.id)
@@ -313,43 +345,41 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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 (failure) return <div className="supervisor-workspace__inline-error" role="alert">
if (!supported) return <>{toolbar}<p className="supervisor-workspace__muted" role="status">{t('supervisor.graph3d.unsupported')}</p></>
if (failure) return <>{toolbar}<div className="supervisor-workspace__inline-error" role="alert">
<strong>{t(failure === 'lost' ? 'supervisor.graph3d.lost' : 'supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={() => { setFailure(undefined); setAttempt(value => value + 1) }}>{t('supervisor.graph3d.retry')}</button>
</div>
if (!range) return <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>
</div></>
if (!range) return <>{toolbar}<p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p></>
const childKinds = new Set(level.children.map(node => node.level))
const childLevelName = t(`supervisor.graph3d.childLevels.${childKinds.size === 1 ? [...childKinds][0] : 'mixed'}`)
const scale = t('supervisor.graph3d.scale', { turn: t(`supervisor.graph3d.turns.${range.turnHours}`), count: Math.round(range.turns * 10) / 10 })
return <div className="story-graph-3d">
<div className="story-graph-3d__toolbar">
<nav aria-label={t('supervisor.graph3d.levels')}>
<div className="supervisor-workspace__canvas-heading story-graph-3d__toolbar">
<nav className="story-graph-3d__breadcrumb" 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 className="story-graph-3d__views">
<SegmentedControl ariaLabel={t('supervisor.graph3d.views')} value={view} onChange={choose}
options={(['oblique', 'side', 'top', 'free'] as const).map(value => ({ value, label: t(`supervisor.graph3d.view.${value}`) }))} />
<button type="button" className="secondary-button" aria-expanded={legendOpen} aria-controls={legendId}
onClick={() => setLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>
{/* Keyboard and screen-reader access to the same staves and experiences shown on the canvas. */}
{/* The record list selects details; this menu drills the actual project/feature hierarchy. */}
<div className="story-graph-3d__pickers">
<PickerMenu label={t('supervisor.graph3d.staves')} button={t('supervisor.graph3d.pickChildren', { level: childLevelName, count: level.children.length })}
items={level.children.map(node => ({ id: node.id, title: node.name, detail: `${t('supervisor.stories.count', { count: node.events.length })} · ${dateText(node.start)} – ${dateText(node.end)}`,
current: node.id === focus.id, onPick: () => open(node) }))} />
<PickerMenu label={t('supervisor.events')} button={t('supervisor.graph3d.pickEvents', { count: focus.events.length })}
items={focus.events.map(event => ({ id: event.id, title: event.title, detail: dateText(event.t),
current: event.id === selectedEventId, onPick: () => onSelectEvent(event.id) }))} />
{shownLinks.length > 0 && <PickerMenu label={t('supervisor.graph3d.experiences')} button={t('supervisor.graph3d.pickExperience', { count: shownLinks.length })}
items={shownLinks.map(link => ({ id: link.id, title: link.statement, detail: `${link.from.stave.name} → ${link.to.stave.name}`,
current: link.id === selectedExperienceId, onPick: () => onSelectExperience?.(link.id) }))} />}
</div>
<span className="supervisor-workspace__muted">{t('supervisor.graph3d.membershipNote')}</span>
<InlineHelp icon="info" label={t('supervisor.graph3d.legendTitle')}>
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<ul>
<li>{t('supervisor.graph3d.legendItems.time', { scale })}</li>
<li>{t('supervisor.graph3d.legendItems.radius')}</li>
<li>{t('supervisor.graph3d.legendItems.stave')}</li>
<li>{t('supervisor.graph3d.legendItems.experience')}</li>
<li>{t('supervisor.graph3d.membershipNote')}</li>
</ul>
</InlineHelp>
{toolbar}
</div>
<div className="story-graph-3d__viewport">
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
@@ -364,7 +394,6 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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
@@ -388,24 +417,9 @@ export default function StoryGraph3D({ stories, events, projectNames, attention:
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()
event.preventDefault(); move[event.key]!(); engine.current?.draw()
}} />
<canvas ref={overlayRef} className="story-graph-3d__overlay" aria-hidden="true" />
{/* Scale stays visible on the canvas; the full explanation opens on demand. */}
<span className="story-graph-3d__scale" aria-hidden="true">{scale}</span>
{legendOpen && <div className="story-graph-3d__legend" id={legendId} role="note">
<div className="story-graph-3d__legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')} title={t('supervisor.graph3d.legendClose')}
onClick={() => setLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul>
<li>{t('supervisor.graph3d.legendItems.time', { scale })}</li>
<li>{t('supervisor.graph3d.legendItems.radius')}</li>
<li>{t('supervisor.graph3d.legendItems.stave')}</li>
<li>{t('supervisor.graph3d.legendItems.experience')}</li>
</ul>
</div>}
</div>
</div>
}
+83 -19
View File
@@ -9,8 +9,54 @@ const render = (ui: React.ReactNode) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
const selectHistory = (id: string) => {
fireEvent.click(screen.getByLabelText('历史结果'))
fireEvent.click(screen.getAllByRole('menuitemradio').find(item => (item as HTMLButtonElement).value === id)!)
}
describe('SupervisorWorkspace', () => {
it.each(['overview', 'graph'] as const)('shows each history result scope and range only inside the %s picker', async tab => {
const old = { ...result, id: 'old', createdAt: '2026-08-02T00:00:00.000Z',
scope: { kind: 'projects', projectIds: ['11111111-1111-4111-8111-111111111111'] },
timeRange: { from: '2026-07-01T00:00:00.000Z', to: '2026-08-01T00:00:00.000Z' } }
window.goodbuddy = { supervision: { overview: async () => [result, old],
graph: async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
render(<SupervisorWorkspace tab={tab} projects={[{ id: old.scope.projectIds[0], name: 'Project One' } as never]} />)
const trigger = await screen.findByLabelText('历史结果')
await waitFor(() => expect(trigger).toBeEnabled())
const date = (value: string) => new Date(value).toLocaleString('zh-CN')
expect(trigger).toHaveTextContent(date(result.createdAt))
expect(trigger).not.toHaveTextContent('全局')
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(date(result.timeRange.from)))
const context = trigger.closest('.supervisor-workspace__review-context')!
expect(context.querySelector('.supervisor-workspace__review-meta')).toBeNull()
expect(Array.from(context.children).map(item => item.tagName)).toEqual(['DIV', 'BUTTON', 'BUTTON'])
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
const options = screen.getAllByRole('menuitemradio')
for (const [index, item] of [result, old].entries()) {
expect(options[index]).toHaveTextContent(date(item.createdAt))
expect(options[index]).toHaveTextContent(date(item.timeRange.from))
expect(options[index]).toHaveTextContent(date(item.timeRange.to))
}
expect(options[0]).toHaveTextContent('全局')
expect(options[1]).toHaveTextContent('Project One')
expect(options[1]).not.toHaveTextContent(date(result.timeRange.from))
expect(options[0]).toHaveFocus()
fireEvent.keyDown(options[0]!, { key: 'End' })
expect(options[1]).toHaveFocus()
fireEvent.click(options[1]!)
await waitFor(() => expect(trigger).toBeEnabled())
expect(trigger).toHaveValue(old.id)
expect(trigger).toHaveTextContent(date(old.createdAt))
expect(trigger).toHaveAccessibleDescription(expect.stringContaining('Project One'))
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(date(old.timeRange.from)))
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
fireEvent.click(trigger)
expect(screen.getAllByRole('menuitemradio')[1]).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(trigger).toHaveFocus()
})
it('defaults the graph history to latest and synchronizes selection in both directions, including empty graphs', async () => {
const old = { ...result, id: 'old', createdAt: '2026-08-01T00:00:00.000Z', summary: 'Old review' }
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
@@ -18,13 +64,18 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
const context = document.querySelector('.supervisor-workspace__review-context')
expect(screen.queryByLabelText('关注范围')).not.toBeInTheDocument()
expect(screen.queryByLabelText('时间范围')).not.toBeInTheDocument()
expect(graph).toHaveBeenLastCalledWith({ resultId: result.id, storyLineId: result.storyLineId })
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: old.id } })
selectHistory(old.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="overview" />)
expect(document.querySelector('.supervisor-workspace__review-context')).toBe(context)
expect(document.querySelector('.supervisor-workspace__recap')).toHaveAttribute('data-sidebar', 'false')
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByText('Old review')).toBeVisible()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
selectHistory(result.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" />)
expect(screen.getByLabelText('历史结果')).toHaveValue(result.id)
@@ -44,8 +95,8 @@ describe('SupervisorWorkspace', () => {
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={navigation} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
expect(screen.getByLabelText('历史结果')).toHaveValue(old.id)
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__graph-canvas')).not.toBeNull()
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: result.id } })
expect(screen.getByLabelText('历史结果').closest('.supervisor-workspace__review-context')).not.toBeNull()
selectHistory(result.id)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ ...navigation }} />)
await screen.findByRole('heading', { name: kind === 'story' ? 'Target story' : 'Target experience' })
@@ -103,7 +154,7 @@ describe('SupervisorWorkspace', () => {
render(<SupervisorWorkspace projects={[project]} onTabChange={onTabChange} onNotify={onNotify} />)
const history = await screen.findByLabelText('历史结果')
await waitFor(() => expect(history).toBeEnabled())
fireEvent.change(history, { target: { value: 'old' } })
selectHistory('old')
await waitFor(() => expect(history).toBeEnabled())
expect(history).toHaveValue('old')
expect(screen.queryByText('最近一次成功回顾')).not.toBeInTheDocument()
@@ -112,11 +163,17 @@ describe('SupervisorWorkspace', () => {
expect(screen.getByText('Pending decision')).toBeVisible()
const recap = screen.getByRole('article')
const frozenText = recap.textContent
expect(recap).toHaveAttribute('data-sidebar', 'true')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: project.id } })
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
expect(screen.queryByLabelText('关注范围')).not.toBeInTheDocument()
expect(history).toHaveValue('old')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
expect(screen.getByLabelText('时间范围')).toHaveValue('30')
expect(screen.getByLabelText('关注范围')).toHaveValue(project.id)
expect(recap.textContent).toBe(frozenText)
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
expect(onTabChange).toHaveBeenCalledWith('graph')
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
@@ -154,6 +211,7 @@ describe('SupervisorWorkspace', () => {
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(cancel).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ message: i18nResources['zh-CN'].heartbeat.reviewSettings.cancelling }))
expect(run).not.toHaveBeenCalled()
@@ -174,6 +232,7 @@ describe('SupervisorWorkspace', () => {
const onNotify = vi.fn()
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith({ tone: 'info', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewBusy, dedupeKey: 'supervisor-review' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -225,7 +284,7 @@ describe('SupervisorWorkspace', () => {
expect(screen.queryByText('Stale A')).not.toBeInTheDocument()
view.rerender(<SupervisorWorkspace graphNavigation={next} />)
expect(screen.getByLabelText('历史结果')).toHaveValue('B')
fireEvent.change(screen.getByLabelText('历史结果'), { target: { value: 'A' } })
selectHistory('A')
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story' }))
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story' }))
@@ -254,16 +313,13 @@ describe('SupervisorWorkspace', () => {
const onTabChange = vi.fn()
const view = render(<SupervisorWorkspace onTabChange={onTabChange} />)
await screen.findByText('B recap')
// The history select is identified by its selected result ID, not scope labels.
const select = [...document.querySelectorAll('select')].find((element) => element.value === 'B')!
fireEvent.change(select, { target: { value: 'A' } })
selectHistory('A')
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story-A' }))
fireEvent.change(select, { target: { value: 'B' } })
const navigation = { resultId: 'B' }
view.rerender(<SupervisorWorkspace graphNavigation={navigation} onTabChange={onTabChange} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story-B' }))
await act(async () => resolveA(graphFor('Wrong A event')))
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
expect(onTabChange).toHaveBeenCalledWith('graph')
view.rerender(<SupervisorWorkspace tab="graph" onTabChange={onTabChange} />)
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
expect(within(await screen.findByRole('group', { name: '故事线图谱' })).getByRole('button', { name: 'B selected' })).toBeInTheDocument()
expect(screen.queryByText('Wrong A event')).not.toBeInTheDocument()
})
@@ -305,6 +361,7 @@ describe('SupervisorWorkspace', () => {
} } as never
render(<SupervisorWorkspace />)
await waitFor(() => expect(screen.getByText('还没有成功回顾')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(window.goodbuddy.supervision.run).toHaveBeenCalledOnce())
expect(window.goodbuddy.supervision.run).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'manual', scope: { kind: 'global' } }))
@@ -320,6 +377,7 @@ describe('SupervisorWorkspace', () => {
} } as never
render(<SupervisorWorkspace />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '更多回顾操作' }))
fireEvent.click(await screen.findByRole('menuitem', { name: '重新整理…' }))
const dialog = screen.getByRole('alertdialog', { name: '重新整理这段时间?' })
@@ -343,6 +401,7 @@ describe('SupervisorWorkspace', () => {
const onNotify = vi.fn()
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewFailed })))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -360,6 +419,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText(copy.supervisor.empty)
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.newReview }))
fireEvent.change(screen.getByLabelText(copy.supervisor.period), { target: { value: '30' } })
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
@@ -377,6 +437,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview: async () => [], run: vi.fn().mockResolvedValue({ status }) } } as never
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText(copy.supervisor.empty)
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.newReview }))
fireEvent.click(screen.getByRole('button', { name: copy.supervisor.run }))
const message = { completed: copy.supervisor.reviewCompleted, paused: copy.reviewSettings.pausedHint,
cancelled: copy.supervisor.reviewCancelled, no_change: copy.supervisor.reviewNoChange, failed: copy.supervisor.reviewFailed }[status]
@@ -396,6 +457,7 @@ describe('SupervisorWorkspace', () => {
render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
expect(onNotify).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(onNotify).toHaveBeenLastCalledWith(expect.objectContaining({ tone: 'error', message: i18nResources['zh-CN'].heartbeat.supervisor.reviewStartFailed })))
expect(run).not.toHaveBeenCalled()
@@ -412,6 +474,7 @@ describe('SupervisorWorkspace', () => {
window.goodbuddy = { supervision: { overview, execution, run, cancel } } as never
const view = render(<SupervisorWorkspace onNotify={onNotify} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(phase === 'preflight' ? execution : run).toHaveBeenCalledOnce())
view.unmount()
@@ -439,12 +502,13 @@ describe('SupervisorWorkspace', () => {
expect(within(canvas).queryByRole('heading', { name: '时间事件' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('group', { name: '故事线图谱' })).toHaveAttribute('width', '100%')
expect(canvas.querySelector('.supervisor-workspace__timeline-ring')).toHaveAttribute('marker-end')
// The legend is closed by default and opens on the canvas, so the graph keeps the column.
// The icon opens shared help without taking space from the canvas.
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '图例' }))
expect(within(canvas).getByRole('list', { name: '图谱图例' })).toBeInTheDocument()
fireEvent.click(within(canvas).getByRole('button', { name: '关闭图例' }))
expect(within(canvas).queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
expect(screen.getByRole('list', { name: '图谱图例' })).toBeInTheDocument()
expect(within(canvas).getByRole('button', { name: '图例' })).toHaveTextContent('')
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('list', { name: '图谱图例' })).not.toBeInTheDocument()
expect(within(canvas).getByRole('slider', { name: '事件浏览' })).toBeDisabled()
expect(graph.querySelector('.supervisor-workspace__entity text')).toHaveTextContent('方案')
expect(graph.querySelector('.supervisor-workspace__node text')).toHaveTextContent('决定')
+99 -96
View File
@@ -1,10 +1,12 @@
import { Component, lazy, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
import { BookOpen, ChevronLeft, ChevronRight, Ellipsis, Info, Network, RefreshCw, X } from 'lucide-react'
import { BookOpen, Check, ChevronDown, ChevronLeft, ChevronRight, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
import { InlineHelp } from './InlineHelp'
import { AnchoredMenu } from './AnchoredMenu'
import type { AppNotificationInput } from './notifications'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import { EmptyState, PageTabs, SegmentedControl } from './WorkspacePrimitives'
import { storyDigest } from './supervision-story-digest'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
import { useFillHeight } from './use-fill-height'
@@ -85,7 +87,6 @@ export function SupervisorWorkspace({
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
const [flatLegendOpen, setFlatLegendOpen] = useState(false)
const [source, setSource] = useState<{
id: string
title: string
@@ -95,6 +96,10 @@ export function SupervisorWorkspace({
}>()
const [projectId, setProjectId] = useState('global')
const [days, setDays] = useState(7)
const [newReviewOpen, setNewReviewOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(Boolean(graphNavigation?.focus))
const detailToggleRef = useRef<HTMLButtonElement>(null)
const detailRef = useRef<HTMLElement>(null)
const runPending = useRef(false)
const mounted = useRef(false)
useEffect(() => {
@@ -102,6 +107,9 @@ export function SupervisorWorkspace({
return () => { mounted.current = false }
}, [])
const [moreOpen, setMoreOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const historyRef = useRef<HTMLButtonElement>(null)
const historyId = useId()
const [confirmReanalyze, setConfirmReanalyze] = useState(false)
const moreRef = useRef<HTMLButtonElement>(null)
const moreId = useId()
@@ -114,6 +122,7 @@ export function SupervisorWorkspace({
setLoading(true)
setSource(undefined)
setSelection(graphNavigation?.focus)
setDetailOpen(Boolean(graphNavigation?.focus))
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
setPending(undefined)
setLoadError(undefined)
@@ -406,6 +415,7 @@ export function SupervisorWorkspace({
const stageEvent = layout.events[stage]
const select = (next: Selection) => {
setSelection(next)
setDetailOpen(true)
setListTab(next.kind)
setSource(undefined)
setConfirmRemoval(false)
@@ -476,32 +486,51 @@ export function SupervisorWorkspace({
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
const graphLayoutRef = useRef<HTMLDivElement>(null)
// The graph column does not scroll: the graph scales to the height left in the window. Below 660px the flat
// graph's labels would drop under 11px, so very short windows keep that height and scroll the page instead.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, GRAPH_MIN_HEIGHT)
// Flat labels need a taller minimum; the spiral can use short windows without that constraint.
useFillHeight(graphLayoutRef, tab === 'graph' && graph.events.length > 0, 24, graphMode === 'spiral' ? 320 : GRAPH_MIN_HEIGHT)
const graphEmptyRef = useRef<HTMLDivElement>(null)
// Without a graph, the empty state takes the visible height so its message sits in the middle of the page.
useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320)
// The work review reads the selected result's own scope, so it matches the period shown.
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
const digest = latest && recapStories.available ? storyDigest(recapStories.view, latest.timeRange) : undefined
const hasDigest = digest && [digest.advanced, digest.started, digest.concluded, digest.experiences, digest.quiet].some(items => items.length > 0)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
useEffect(() => {
if (!detailOpen || !detailRef.current || !detailToggleRef.current || getComputedStyle(detailToggleRef.current).display === 'none') return
detailRef.current.focus({ preventScroll: true })
detailRef.current.scrollIntoView?.({ block: 'nearest' })
}, [detailOpen, selection?.id, tab])
const busy = !!api && (loading || pending !== undefined)
const historyDetails = (item: SupervisionResultView) =>
`${scopeText(item.scope)} · ${t('supervisor.period')}: ${date(item.timeRange.from)} – ${date(item.timeRange.to)}`
const historySelector = results.length > 0 && <div className="supervisor-workspace__result-navigation">
<label>
{t('supervisor.history')}
<select value={resultId ?? ''} disabled={busy}
onChange={(event) => { setSelection(undefined); setSource(undefined); void refresh(event.target.value) }}>
{!results.some((item) => item.id === resultId) && <option value={resultId ?? ''}>{t('supervisor.loading')}</option>}
{results.map((item) => <option key={item.id} value={item.id}>
{date(item.createdAt)} · {scopeText(item.scope)}
</option>)}
</select>
</label>
{tab === 'overview' && latest && <button className="secondary-button" onClick={() => onTabChange?.('graph')}>
{t('supervisor.graph')}
</button>}
<span id={`${historyId}-label`}>{t('supervisor.history')}</span>
<button ref={historyRef} type="button" className="model-button" value={resultId ?? ''} disabled={busy}
aria-labelledby={`${historyId}-label`} aria-describedby={`${historyId}-context`}
aria-haspopup="menu" aria-expanded={historyOpen && !busy} aria-controls={historyOpen && !busy ? historyId : undefined}
onClick={() => setHistoryOpen(value => !value)}
onKeyDown={event => { if (event.key === 'ArrowDown') { event.preventDefault(); setHistoryOpen(true) } }}>
<span className="model-button__label">{latest ? date(latest.createdAt) : t('supervisor.loading')}</span>
<ChevronDown size={14} aria-hidden="true" />
</button>
<span id={`${historyId}-context`} className="sr-only">{latest && `${t('supervisor.generatedAt', { time: date(latest.createdAt) })} · ${historyDetails(latest)}`}</span>
{historyOpen && !busy && <AnchoredMenu anchorRef={historyRef} id={historyId} label={t('supervisor.history')}
width={480} className="supervisor-workspace__history-menu" onClose={() => setHistoryOpen(false)}>
{results.map(item => <button key={item.id} type="button" role="menuitemradio" value={item.id} aria-checked={item.id === resultId}
onClick={() => { setHistoryOpen(false); historyRef.current?.focus(); setSelection(undefined); setSource(undefined); setDetailOpen(false); void refresh(item.id) }}>
<span><span>{t('supervisor.generatedAt', { time: date(item.createdAt) })}</span><small>{historyDetails(item)}</small></span>
{item.id === resultId && <Check size={16} aria-hidden="true" />}
</button>)}
</AnchoredMenu>}
</div>
const graphTools = <>
{storyState.available && <div className="supervisor-workspace__graph-mode"><SegmentedControl ariaLabel={t('supervisor.graph3d.mode')}
value={graphMode} onChange={setGraphMode} options={(['flat', 'spiral'] as const).map(value => ({ value, label: t(`supervisor.graph3d.modes.${value}`) }))} /></div>}
<button ref={detailToggleRef} type="button" className="secondary-button supervisor-workspace__detail-toggle"
aria-expanded={detailOpen} aria-controls={`${graphId}-detail`} onClick={() => setDetailOpen(value => !value)}>{t('supervisor.inspector')}</button>
</>
return (
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
@@ -515,6 +544,12 @@ export function SupervisorWorkspace({
</div>
) : (
<>
{(tab === 'overview' || tab === 'graph') && <div className="supervisor-workspace__review-context" role="group" aria-label={t('supervisor.recap')}>
{historySelector}
<button type="button" className="secondary-button" disabled={busy} onClick={() => void refresh()}>{t('center.actions.refresh')}</button>
<button type="button" className={newReviewOpen ? 'secondary-button' : 'primary-button'} aria-expanded={newReviewOpen} aria-controls={`${graphId}-new-review`}
onClick={() => { setNewReviewOpen(value => !value); setConfirmReanalyze(false) }}>{t('supervisor.newReview')}</button>
</div>}
{loading && (
<EmptyState
variant="loading"
@@ -536,9 +571,9 @@ export function SupervisorWorkspace({
</button>
</div>
)}
{tab === 'overview' && (
{newReviewOpen && (tab === 'overview' || tab === 'graph') && (
<>
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<div id={`${graphId}-new-review`} className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<label>
<select
aria-label={t('supervisor.scope')}
@@ -574,13 +609,6 @@ export function SupervisorWorkspace({
>
{t('supervisor.run')}
</button>
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
<button ref={moreRef} type="button" className="icon-button" aria-label={t('supervisor.more')} title={t('supervisor.more')}
aria-haspopup="menu" aria-expanded={moreOpen} aria-controls={moreOpen ? moreId : undefined}
onClick={() => setMoreOpen(!moreOpen)}><Ellipsis size={16} aria-hidden="true" /></button>
@@ -602,19 +630,12 @@ export function SupervisorWorkspace({
<button type="button" className="primary-button" onClick={() => void run(true)}>{t('supervisor.reanalyzeConfirm')}</button>
</div>
</div>}
{historySelector}
{latest && recapStories.available && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
</>
)}
{tab === 'overview' && (
<>
{latest ? (
<article className="supervisor-workspace__recap">
<div className="supervisor-workspace__section-heading">
<h2>{t('supervisor.latest')}</h2>
<time dateTime={latest.createdAt}>{date(latest.createdAt)}</time>
</div>
<p>
{scopeText(latest.scope)} · {date(latest.timeRange.from)} –{' '}
{date(latest.timeRange.to)}
</p>
<article className="supervisor-workspace__recap" data-sidebar={Boolean(latest.openItems.length || hasDigest)}>
<div className="supervisor-workspace__prose">
<h3>{t('supervisor.summary')}</h3>
{latest.summary.split(/\r?\n\s*\r?\n/u).map((paragraph, index) => (
@@ -624,6 +645,8 @@ export function SupervisorWorkspace({
<h3>{t('supervisor.changes')}</h3>
<p className="supervisor-workspace__summary">{latest.changeDigest}</p>
</>}
</div>
{(latest.openItems.length > 0 || hasDigest) && <aside className="supervisor-workspace__recap-sidebar">
{latest.openItems.length > 0 && (
<>
<h3>{t('supervisor.openItems')}</h3>
@@ -634,7 +657,9 @@ export function SupervisorWorkspace({
</ul>
</>
)}
</div>
{hasDigest && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
</aside>}
</article>
) : (
!loading &&
@@ -650,8 +675,6 @@ export function SupervisorWorkspace({
)}
{tab === 'graph' && (
<>
{/* With a graph, scope and refresh live in the canvas heading; without one, they sit in the centred empty state. */}
{!graph.events.length && historySelector}
{!loading && !loadError && !graph.events.length && (
<div className="supervisor-workspace__graph-empty" ref={graphEmptyRef}>
<EmptyState
@@ -666,24 +689,22 @@ export function SupervisorWorkspace({
>
{t('supervisor.recap')}
</button>
<button
className="secondary-button"
disabled={busy}
onClick={() => void refresh()}
>
{t('center.actions.refresh')}
</button>
</div>
}
/>
</div>
)}
{graph.events.length > 0 && (
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef}>
<div className="supervisor-workspace__graph-layout" ref={graphLayoutRef} data-detail-open={detailOpen} data-mode={graphMode}>
<aside
className="supervisor-workspace__graph-list"
aria-label={t('supervisor.selection')}
>
<select className="field-control supervisor-workspace__category" aria-label={t('supervisor.selection')}
value={listTab} onChange={event => setListTab(event.target.value as Selection['kind'])}>
{(['event', 'entity', 'relation', ...(storyState.available ? ['story', 'experience'] as const : [])] as const).map(kind =>
<option key={kind} value={kind}>{t(`supervisor.listTabs.${kind}`)} {kind === 'event' ? layout.events.length : kind === 'entity' ? layout.entities.length : kind === 'relation' ? graph.relations.length : kind === 'story' ? storyState.view.stories.length : storyState.view.experiences.length}</option>)}
</select>
<PageTabs
ariaLabel={t('supervisor.selection')}
idPrefix={`${graphId}-list`}
@@ -792,41 +813,33 @@ export function SupervisorWorkspace({
className="supervisor-workspace__graph-canvas"
aria-label={t('supervisor.canvas')}
>
<div className="supervisor-workspace__canvas-heading">
{/* History stays inside the canvas column with its title and review metadata. */}
<div className="supervisor-workspace__canvas-title">
{historySelector}
<strong>{t('supervisor.canvasTitle')}</strong>
<span>
{[
graphScope && `${t('supervisor.graphScope')}: ${scopeText(graphScope)}`,
latest && `${date(latest.timeRange.from)} – ${date(latest.timeRange.to)}`,
latest && t('supervisor.generatedAt', { time: date(latest.createdAt) }),
t('supervisor.counts', { events: layout.events.length, entities: layout.entities.length })
].filter(Boolean).join(' · ')}
</span>
</div>
<button type="button" className="icon-button" aria-label={t('center.actions.refresh')} title={t('center.actions.refresh')}
disabled={busy} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
{graphMode === 'flat' && <div className="supervisor-workspace__canvas-tools">
{graphMode === 'flat' && <div className="supervisor-workspace__canvas-heading">
<strong>{t('supervisor.graph3d.all')}</strong>
<div className="supervisor-workspace__canvas-tools">
{selection && <button type="button" className="link-button" onClick={() => { setSelection(undefined); setSource(undefined) }}>
{t('supervisor.showAll')}</button>}
<button type="button" className="secondary-button" aria-expanded={flatLegendOpen} aria-controls={`${graphId}-legend`}
onClick={() => setFlatLegendOpen(value => !value)}><Info size={14} aria-hidden="true" />{t('supervisor.graph3d.legendTitle')}</button>
</div>}
{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>
<InlineHelp icon="info" label={t('supervisor.graph3d.legendTitle')}>
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<ul className="supervisor-workspace__legend" aria-label={t('supervisor.legendLabel')}>
<li><i className="supervisor-workspace__legend-event" aria-hidden="true" />{t('supervisor.events')}</li>
<li><i className="supervisor-workspace__legend-entity" aria-hidden="true" />{t('supervisor.entities')}</li>
<li><i className="supervisor-workspace__legend-link" aria-hidden="true" />{t('supervisor.eventImpact')}</li>
<li><i className="supervisor-workspace__legend-relation" aria-hidden="true" />{t('supervisor.relations')}</li>
</ul>
<p>{t('supervisor.canvasNote')}</p>
<p>{t('supervisor.canvasCaption')}</p>
</InlineHelp>
</div>
{graphTools}
</div>}
{graphMode === 'spiral' ? (
<SpiralBoundary fallback={(retry) => <div className="supervisor-workspace__inline-error" role="alert">
<SpiralBoundary fallback={(retry) => <><div className="supervisor-workspace__canvas-heading">{graphTools}</div><div className="supervisor-workspace__inline-error" role="alert">
<strong>{t('supervisor.graph3d.failed')}</strong>
<button type="button" className="secondary-button" onClick={retry}>{t('supervisor.graph3d.retry')}</button>
<button type="button" className="link-button" onClick={() => setGraphMode('flat')}>{t('supervisor.graph3d.modes.flat')}</button>
</div>}>
<Suspense fallback={<p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p>}>
</div></>}>
<Suspense fallback={<><div className="supervisor-workspace__canvas-heading">{graphTools}</div><p className="supervisor-workspace__muted" role="status">{t('supervisor.loading')}</p></>}>
<StoryGraph3D stories={storyState.view.stories} events={graph.events} projectNames={projectNames} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
toolbar={graphTools}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
@@ -1023,21 +1036,6 @@ export function SupervisorWorkspace({
<span className="supervisor-workspace__map-hint" aria-hidden="true">
{t('supervisor.visibleCounts', { events: layout.visibleEvents.length, entities: layout.visibleEntities.length })}
</span>
{flatLegendOpen && <div className="supervisor-workspace__map-legend" id={`${graphId}-legend`} role="note">
<div className="supervisor-workspace__map-legend-head">
<strong>{t('supervisor.graph3d.legendTitle')}</strong>
<button type="button" className="icon-button" aria-label={t('supervisor.graph3d.legendClose')}
title={t('supervisor.graph3d.legendClose')} onClick={() => setFlatLegendOpen(false)}><X size={14} aria-hidden="true" /></button>
</div>
<ul className="supervisor-workspace__legend" aria-label={t('supervisor.legendLabel')}>
<li><i className="supervisor-workspace__legend-event" aria-hidden="true" />{t('supervisor.events')}</li>
<li><i className="supervisor-workspace__legend-entity" aria-hidden="true" />{t('supervisor.entities')}</li>
<li><i className="supervisor-workspace__legend-link" aria-hidden="true" />{t('supervisor.eventImpact')}</li>
<li><i className="supervisor-workspace__legend-relation" aria-hidden="true" />{t('supervisor.relations')}</li>
</ul>
<p>{t('supervisor.canvasNote')}</p>
<p>{t('supervisor.canvasCaption')}</p>
</div>}
</figure>
{stageEvent && (
<div className="supervisor-workspace__playback">
@@ -1093,9 +1091,15 @@ export function SupervisorWorkspace({
</>}
</section>
<aside
ref={detailRef}
tabIndex={-1}
id={`${graphId}-detail`}
className="supervisor-workspace__detail"
aria-label={t('supervisor.inspector')}
onKeyDown={(event) => { if (event.key === 'Escape' && detailOpen) { event.stopPropagation(); setDetailOpen(false); detailToggleRef.current?.focus() } }}
>
<button type="button" className="icon-button supervisor-workspace__detail-toggle" aria-label={t('supervisor.closeDetails')}
onClick={() => { setDetailOpen(false); detailToggleRef.current?.focus() }}><X size={16} aria-hidden="true" /></button>
<h2>{t('supervisor.inspector')}</h2>
{selection?.kind === 'experience' ? (
selectedExperience ? <>
@@ -1119,7 +1123,6 @@ export function SupervisorWorkspace({
: selectedEntity
? t('supervisor.entities')
: t('supervisor.relations')}
{graphScope && ` · ${scopeText(graphScope)}`}
</div>
<h3>
{selectedEvent?.title ??
+5 -4
View File
@@ -12,7 +12,7 @@
-webkit-app-region: no-drag;
}
.anchored-menu [role="menuitem"] {
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]) {
display: flex;
align-items: center;
gap: var(--space-2);
@@ -29,8 +29,9 @@
cursor: pointer;
}
.anchored-menu [role="menuitem"] svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu [role="menuitem"]:hover { background: var(--surface-subtle); }
.anchored-menu [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]) svg { flex-shrink: 0; color: var(--text-secondary); }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]):hover { background: var(--surface-subtle); }
.anchored-menu :is([role="menuitem"], [role="menuitemradio"]):focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.anchored-menu [role="menuitemradio"][aria-checked="true"] { background: var(--accent-subtle); font-weight: 600; }
.anchored-menu [role="separator"] { border-top: 1px solid var(--border-subtle); margin: var(--space-2) 0; }
.anchored-menu p { margin: var(--space-2); color: var(--text-secondary); overflow-wrap: anywhere; }
@@ -110,7 +110,7 @@ export const heartbeat = {
canvasTitle: 'Events and knowledge', generatedAt: 'Generated {{time}}', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
visibleCounts: '{{events}} events / {{entities}} entities on canvas', showAll: 'Show all connections', connections: 'Graph connections',
loadingHint: 'Reading saved reviews, events, and sources.', unavailableHint: 'Reopen the application to try again. Saved reviews remain on this device.',
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources',
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources', closeDetails: 'Close details',
counts: '{{events}} events · {{entities}} entities', canvasCaption: 'Events are evenly spaced in time order, not by elapsed time. Each batch shows up to 8 events and 6 entities; select from the list to switch batches and read full names. Scroll the canvas horizontally on narrow screens. Entity states reflect the selected review result.',
legendLabel: 'Graph legend', eventImpact: 'Event impact', playback: 'Browse events', previousStage: 'Previous event', nextStage: 'Next event',
navigation: 'Supervisor views', recap: 'Work review', graph: 'Story graph', settings: 'Settings',
@@ -133,14 +133,14 @@ export const heartbeat = {
},
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 toolbar lists offer the same items.',
levels: 'Stave levels', all: 'All', views: 'View', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level. Use the level menu to drill down and the sidebar to select records.',
view: { oblique: 'Oblique', side: 'Side', top: 'Top', free: 'Free' },
staves: 'Stories at this level',
experiences: 'Experiences across stories',
scale: 'One turn {{turn}} · {{count}} turns',
legendTitle: 'Legend', legendClose: 'Close legend',
legendItems: {
time: 'Height is time; {{scale}}.',
time: 'Height is time; {{scale}}. Colored segments cover actual event intervals and match the primary story. Gaps stay gray; timestamp-only events are dots. Overlaps show the later-starting event; unassigned events use neutral marks.',
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.'
@@ -107,7 +107,7 @@ export const heartbeat = {
canvasTitle: '事件与知识', generatedAt: '生成于 {{time}}', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
visibleCounts: '画布中 {{events}} 个事件 / {{entities}} 个实体', showAll: '显示全部关联', connections: '图谱关联',
loadingHint: '正在读取已保存的回顾、事件与来源。', unavailableHint: '请重新打开应用后重试;已保存的回顾仍保留在本机。',
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源',
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源', closeDetails: '关闭详情',
counts: '{{events}} 个事件 · {{entities}} 个实体', canvasCaption: '事件按时间顺序等距排列,不表示时间间隔。画布每批最多显示 8 个事件和 6 个实体;从列表选择可切换批次并查看完整名称,窄屏可横向滚动画布。实体状态以所选回顾结果为准。',
legendLabel: '图谱图例', eventImpact: '事件影响', playback: '事件浏览', previousStage: '上一事件', nextStage: '下一事件',
navigation: '监督者视图', recap: '工作回顾', graph: '故事线图谱', settings: '设置',
@@ -130,14 +130,14 @@ export const heartbeat = {
},
graph3d: {
mode: '图谱视图', modes: { flat: '平铺', spiral: '时间螺旋' },
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;工具栏的列表提供同样的内容。',
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;层级菜单用于下钻,左侧列表用于选择记录。',
view: { oblique: '斜俯视', side: '侧视', top: '俯视', free: '自由' },
staves: '当前层级的故事',
experiences: '跨故事的经验',
scale: '一圈 {{turn}} · 共 {{count}} 圈',
legendTitle: '图例', legendClose: '关闭图例',
legendItems: {
time: '高度为时间,{{scale}}。',
time: '高度为时间,{{scale}}。彩色线段只表示事件的实际起止区间,颜色对应主故事;空档为灰线,仅有时间点的事件显示为圆点。重叠区间显示较晚开始的事件,未归属事件用中性色标记。',
radius: '半径比较本次回顾内每小时的用户与助手消息数。密度相同则半径相同,无消息记录时取最小半径,不代表投入时长或工作效率。',
stave: '木片上下跨度为故事的起止时间。',
experience: '桶外菱形是经验,弧线从形成它的木片连到后来用上它的木片。'
+32 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, visibleLevel } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, timelineSegments, 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()
@@ -25,9 +25,10 @@ describe('story graph 3d model', () => {
['feature', ['exact-member']], ['unassigned', ['old-extraction', 'never-assigned']]
])
expect(tree.children[1]!.name).toBe('Unknown')
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)) })
expect(tree.events.at(-1)).toEqual({ id: 'exact-member', title: 'Same title', t: Date.parse(at(3.5)), end: Date.parse(at(3.5)), storyId: 'feature' })
const beforeStories = buildStoryTree([], new Map([['p1', 'Project']]), { events, unassigned: 'Unassigned', unknownProject: 'Unknown' })
expect(beforeStories.events).toEqual(tree.events)
expect(beforeStories.events.map(event => event.id)).toEqual(tree.events.map(event => event.id))
expect(beforeStories.events.every(event => !event.storyId)).toBe(true)
expect(beforeStories.children.flatMap(project => project.children).every(node => node.level === 'unassigned')).toBe(true)
})
@@ -57,6 +58,34 @@ describe('story graph 3d model', () => {
expect(storyWindow(0, 3 * day, 3).turnHours).toBe(6)
})
it('colors only saved intervals with primary membership, keeping points, gaps and overlaps truthful', () => {
const feature = story('feature', 'feature', [1])
const cross = story('cross', 'cross', [1], { events: feature.events.map(event => ({ ...event, primary: false })) })
const tree = buildStoryTree([feature, cross], new Map(), { unassigned: 'Unassigned', unknownProject: 'Unknown', events: [
{ id: 'feature-0', title: 'Saved interval', description: '', occurred_at: at(1), started_at: at(1), ended_at: at(3) },
{ id: 'point', title: 'Point', description: '', occurred_at: at(5) }
] })
expect(tree.events[0]).toMatchObject({ t: Date.parse(at(1)), end: Date.parse(at(3)), storyId: 'feature' })
expect(tree.children[0]!.end).toBe(Date.parse(at(3)))
expect(tree.children.find(node => node.level === 'cross')!.events[0]!.storyId).toBe('feature')
const segments = timelineSegments(tree.events, storyWindow(Date.parse(at(0)), Date.parse(at(6))))
expect(segments.map(segment => [(segment.from - Date.parse(at(0))) / day, (segment.to - Date.parse(at(0))) / day, segment.event?.storyId])).toEqual([
[0, 1, undefined], [1, 3, 'feature'], [3, 6, undefined]
])
const overlapping = [
{ id: 'a', title: '', t: -1, end: 6, storyId: 'one' },
{ id: 'b', title: '', t: 2, end: 4, storyId: 'two' },
{ id: 'c', title: '', t: 2, end: 3, storyId: 'three' },
{ id: 'point', title: '', t: 7, end: 7 },
{ id: 'unassigned', title: '', t: 8, end: 12 }
]
const mapped = timelineSegments(overlapping, storyWindow(0, 10))
expect(mapped.map(segment => [segment.from, segment.to, segment.event?.id])).toEqual([
[0, 2, 'a'], [2, 3, 'b'], [3, 4, 'b'], [4, 6, 'a'], [6, 8, undefined], [8, 10, 'unassigned']
])
expect(timelineSegments([...overlapping].reverse(), storyWindow(0, 10))).toEqual(mapped)
})
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 }]
+16 -4
View File
@@ -21,7 +21,7 @@ export type StoryNode = {
a1: number
concluded: boolean
}
export type StoryEventPoint = { id: string; title: string; t: number }
export type StoryEventPoint = { id: string; title: string; t: number; end?: number; storyId?: string }
export type StoryWindow = { from: number; to: number; turnHours: number; turns: number }
const TAU = Math.PI * 2
@@ -34,11 +34,15 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
}): StoryNode {
const events = review && new Map(review.events.map(event => [event.id, event]))
if (events) stories = stories.map(story => ({ ...story, events: story.events.filter(event => events.has(event.id)) }))
const owners = new Map(stories.filter(story => story.level !== 'cross').flatMap(story =>
story.events.filter(event => event.primary).map(event => [event.id, story.id] as const)))
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 => {
const saved = events?.get(event.id)
return { id: event.id, title: saved?.title ?? event.title, t: Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt) }
const t = Date.parse(saved?.started_at ?? saved?.occurred_at ?? event.startedAt)
const end = Date.parse(saved ? saved.ended_at ?? saved.started_at ?? saved.occurred_at : event.endedAt)
return { id: event.id, title: saved?.title ?? event.title, t, end: Number.isFinite(end) ? Math.max(t, end) : t, storyId: owners.get(event.id) }
}),
start: 0, end: 0, a0: 0, a1: 0, concluded: story.state === 'concluded' })
const projects = new Map<string, StoryNode>()
@@ -73,7 +77,8 @@ export function buildStoryTree(stories: SupervisionStory[], projectNames: Map<st
children: [], events: [], start: 0, end: 0, a0: 0, a1: 0, concluded: false }
project.children.push(unassigned)
}
unassigned.events.push({ id: event.id, title: event.title, t: Date.parse(event.started_at ?? event.occurred_at) })
const t = Date.parse(event.started_at ?? event.occurred_at), end = Date.parse(event.ended_at ?? event.started_at ?? event.occurred_at)
unassigned.events.push({ id: event.id, title: event.title, t, end: Number.isFinite(end) ? Math.max(t, end) : t })
}
}
finish(root)
@@ -90,7 +95,7 @@ function finish(node: StoryNode): void {
}
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.end = node.events.reduce((end, event) => Math.max(end, event.end ?? event.t), node.start)
node.children = node.children.filter(child => child.events.length > 0)
}
@@ -149,6 +154,13 @@ export function radiusLevels(window: StoryWindow, attention: SupervisionAttentio
}
export type StoryCluster = { t0: number; t1: number; t: number; events: StoryEventPoint[] }
/** Exact interval boundaries; overlapping events use the latest start, then stable ID. Points do not fill gaps. */
export function timelineSegments(events: StoryEventPoint[], window: StoryWindow): Array<{ from: number; to: number; event?: StoryEventPoint }> {
const intervals = events.filter(event => (event.end ?? event.t) > event.t && event.t < window.to && event.end! > window.from)
.sort((a, b) => b.t - a.t || a.id.localeCompare(b.id))
const boundaries = [...new Set([window.from, window.to, ...intervals.flatMap(event => [Math.max(window.from, event.t), Math.min(window.to, event.end!)])])].sort((a, b) => a - b)
return boundaries.slice(0, -1).map((from, index) => ({ from, to: boundaries[index + 1]!, event: intervals.find(event => event.t <= from && event.end! > from) }))
}
/** 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[] = []
+121 -119
View File
@@ -194,7 +194,6 @@
min-width: 0;
}
.supervisor-workspace__toolbar label,
.supervisor-workspace__result-navigation label,
.supervisor-workspace__recap label,
.supervisor-workspace__detail label {
display: grid;
@@ -203,7 +202,6 @@
font-size: var(--font-caption);
}
.supervisor-workspace__toolbar select,
.supervisor-workspace__result-navigation select,
.supervisor-workspace__recap select,
.supervisor-workspace__detail input {
min-height: 32px;
@@ -353,31 +351,27 @@
margin-top: var(--space-2);
}
.supervisor-workspace__result-navigation {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--space-3);
margin-top: var(--space-6);
}
.supervisor-workspace__result-navigation label {
flex: 1 1 240px;
min-width: 0;
}
.supervisor-workspace__result-navigation select {
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 {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
color: var(--text-secondary);
font-size: var(--font-caption);
}
.supervisor-workspace[data-view="graph"] .supervisor-workspace__result-navigation select {
.supervisor-workspace__result-navigation .model-button {
flex: 1;
min-width: 0;
}
.supervisor-workspace__history-menu [role="menuitemradio"] > span {
flex: 1;
min-width: 0;
}
.supervisor-workspace__history-menu small {
display: block;
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: normal;
}
.supervisor-workspace__recap li {
margin: var(--space-2) 0;
@@ -400,7 +394,7 @@
/*
* Wide layouts: the graph row is as tall as the window allows (`--fill-height`, set by useFillHeight with a
* steady bottom margin), and each column in it scrolls on its own, so long lists never grow the page.
* With three columns that is the whole layout; with two, the detail wraps below at its natural height.
* At medium widths the detail opens inside the graph row; it never grows a second grid row.
* Single-column layouts keep page scrolling.
*/
@container supervisor (min-width: 861px) {
@@ -455,12 +449,6 @@
scrollbar-gutter: stable;
}
}
@container supervisor (min-width: 861px) and (max-width: 1190px) {
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
max-height: none;
overflow-y: visible;
}
}
.supervisor-workspace__canvas-heading {
display: flex;
justify-content: space-between;
@@ -471,17 +459,9 @@
}
.supervisor-workspace__canvas-heading {
align-items: center;
}
.supervisor-workspace__canvas-title {
display: grid;
gap: 2px;
flex: 1 1 240px;
min-width: 0;
}
.supervisor-workspace__canvas-title span {
color: var(--text-muted);
font-size: var(--font-caption);
overflow-wrap: anywhere;
flex: 0 0 auto;
font-size: var(--font-body);
background: var(--surface-raised);
}
.supervisor-workspace__figure {
position: relative;
@@ -512,39 +492,11 @@
font-size: var(--font-caption);
pointer-events: none;
}
.supervisor-workspace__map-legend {
position: absolute;
inset-inline-end: var(--space-3);
inset-block-start: var(--space-3);
z-index: 1;
width: min(300px, calc(100% - 2 * var(--space-3)));
max-height: calc(100% - 2 * var(--space-3));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.supervisor-workspace__map-legend-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-primary);
}
.supervisor-workspace__map-legend .supervisor-workspace__legend {
.inline-help__content .supervisor-workspace__legend {
padding: var(--space-2) 0;
border-top: 0;
gap: var(--space-2) var(--space-3);
}
.supervisor-workspace__map-legend p {
margin: var(--space-1) 0 0;
}
.supervisor-workspace__figure figcaption {
padding: 0 var(--space-4) var(--space-4);
color: var(--text-secondary);
@@ -947,10 +899,6 @@
.supervisor-workspace__graph-layout {
grid-template-columns: 184px minmax(0, 1fr);
}
.supervisor-workspace__detail {
grid-column: 1 / -1;
border-top: 1px solid var(--border-default);
}
.supervisor-workspace__graph-canvas {
border-right: 0;
}
@@ -1212,17 +1160,10 @@
.story-graph-3d {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-4);
}
.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);
justify-content: flex-start;
}
.story-graph-3d__toolbar > div {
display: flex;
@@ -1230,18 +1171,20 @@
gap: var(--space-2);
min-width: 0;
}
.story-graph-3d__views {
margin-inline-start: auto;
.story-graph-3d__breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.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;
}
@@ -1269,7 +1212,7 @@
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
flex-basis: 100%;
flex: 0 1 auto;
}
.story-graph-3d__picker {
display: inline-flex;
@@ -1277,6 +1220,12 @@
gap: var(--space-1);
min-width: 0;
max-width: 100%;
min-height: 28px;
padding: var(--space-1) var(--space-2);
font-size: var(--font-body);
font-weight: 400;
border-color: var(--border-subtle);
background: var(--surface-subtle);
}
.story-graph-3d__picker > span {
overflow: hidden;
@@ -1318,42 +1267,95 @@
.anchored-menu.story-graph-3d__menu [role="menuitem"] > svg {
color: var(--accent);
}
.story-graph-3d__scale {
position: absolute;
inset-inline-start: var(--space-2);
inset-block-end: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-control);
background: color-mix(in srgb, var(--surface-raised) 85%, transparent);
color: var(--text-muted);
font-size: var(--font-caption);
pointer-events: none;
}
.story-graph-3d__legend {
position: absolute;
inset-inline-end: var(--space-2);
inset-block-start: var(--space-2);
width: min(280px, calc(100% - 2 * var(--space-2)));
max-height: calc(100% - 2 * var(--space-2));
overflow: auto;
box-sizing: border-box;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
font-size: var(--font-caption);
}
.story-graph-3d__legend-head {
/* Review context is shared by both result views, independently of new-review inputs. */
.supervisor-workspace__review-context {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding-block: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
.story-graph-3d__legend ul {
.supervisor-workspace__review-context .supervisor-workspace__result-navigation {
margin: 0;
flex: 0 1 280px;
min-width: 0;
}
.supervisor-workspace__review-context > button { flex-shrink: 0; }
.supervisor-workspace__toolbar {
padding: var(--space-3);
background: var(--surface-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.supervisor-workspace__recap {
display: grid;
gap: var(--space-1);
margin: var(--space-1) 0 0;
padding-inline-start: 1.1em;
color: var(--text-muted);
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
border: 0;
border-radius: 0;
}
.supervisor-workspace__recap > * { min-width: 0; }
.supervisor-workspace__recap h3:first-child { margin-top: 0; }
.supervisor-workspace__recap-sidebar {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-4);
}
.supervisor-workspace__recap-sidebar .supervisor-digest {
margin: var(--space-4) 0 0;
padding: var(--space-4) 0 0;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0;
}
.supervisor-workspace__recap-sidebar .supervisor-digest:first-child { margin: 0; padding: 0; border: 0; }
.supervisor-workspace__recap-sidebar .supervisor-digest h2 { font-size: var(--font-section-title); }
.supervisor-workspace__recap-sidebar .supervisor-digest__grid { grid-template-columns: minmax(0, 1fr); }
@container supervisor (min-width: 861px) {
.supervisor-workspace__recap[data-sidebar='true'] { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.supervisor-workspace__recap-sidebar {
border-top: 0;
border-left: 1px solid var(--border-subtle);
padding: 0 0 0 var(--space-6);
}
}
.supervisor-workspace__graph-layout { position: relative; }
.supervisor-workspace__detail-toggle { display: none; }
@container supervisor (max-width: 1190px) {
.supervisor-workspace__detail-toggle { display: inline-flex; }
.supervisor-workspace__graph-layout > .supervisor-workspace__detail {
display: none;
position: absolute;
inset: 0 0 0 auto;
z-index: 2;
width: min(360px, 100%);
max-height: 100%;
box-sizing: border-box;
overflow-y: auto;
overscroll-behavior: contain;
border-left: 1px solid var(--border-default);
box-shadow: var(--shadow-card);
}
.supervisor-workspace__graph-layout[data-detail-open='true'] > .supervisor-workspace__detail { display: block; }
}
.supervisor-workspace__category { display: none; }
@container supervisor (max-width: 1190px) {
.supervisor-workspace__category { display: block; width: 100%; flex-shrink: 0; }
.supervisor-workspace__graph-list > .page-tabs { display: none; }
}
@container supervisor (max-width: 860px) {
.supervisor-workspace__graph-canvas {
display: flex;
flex-direction: column;
height: var(--fill-height, 660px);
min-height: 0;
overflow: hidden;
}
.supervisor-workspace__graph-canvas > .supervisor-workspace__figure,
.supervisor-workspace__graph-canvas > .story-graph-3d { flex: 1 1 0; min-height: 0; }
.supervisor-workspace__graph-canvas .supervisor-workspace__map-scroll { position: absolute; inset: 0; overflow: hidden; }
.supervisor-workspace__graph-canvas .supervisor-workspace__map { min-width: 0; height: 100%; max-height: none; }
.supervisor-workspace__graph-canvas .story-graph-3d__viewport { flex: 1 1 0; min-height: 0; aspect-ratio: auto; }
.supervisor-workspace__graph-layout > .supervisor-workspace__detail { max-height: var(--fill-height, 660px); }
}