fix: reduce supervisor stalls and preserve task delivery
Load Supervisor graphs only when opened, preserve source details on demand, reuse indexed story digests and window selection lists without losing keyboard or focus behavior. Route heavy UI reads through the existing worker, batch scoped queries and construct each publication snapshot once. Keep remote cancellation responsive during model-route cleanup, remove late routes and retain reported usage after response-cap cancellation. Preserve Telegram pending replies through polling conflicts, show delivery errors and honor the final rate-limit cooldown. Schema 62 adds lookup indexes without rewriting user content; older clients cannot open upgraded data. Large review publication still blocks Main and remains documented follow-up work. Record the future storage-service direction without performing the architecture migration. Validation: exact staged code passed isolated full tests (6212 passed, 87 skipped), typecheck and lint. Three-round source performance comparisons, SQLite migration equivalence, native Electron interaction checks and current-source Linux x64 Host validation completed; eight external text-model requests. Release note: Supervisor opens reviews without loading the entire graph, keeps long selection lists responsive and preserves details and keyboard navigation. Remote cancellation and Telegram reply recovery are more reliable; remote fixes require an Agent build containing this change.
This commit is contained in:
@@ -14110,6 +14110,25 @@ describe("App", () => {
|
||||
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reload artifacts on first or warm Supervisor entry", async () => {
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(api.artifacts.list).toHaveBeenCalled();
|
||||
expect(api.heartbeats.history).toHaveBeenCalled();
|
||||
});
|
||||
const artifactReads = vi.mocked(api.artifacts.list).mock.calls.length;
|
||||
const historyReads = vi.mocked(api.heartbeats.history).mock.calls.length;
|
||||
fireEvent.click(await screen.findByRole("button", { name: "监督者" }));
|
||||
await screen.findByRole("heading", { name: "监督者" });
|
||||
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 1));
|
||||
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
|
||||
const navigation = within(screen.getByRole("navigation", { name: "主导航" }));
|
||||
fireEvent.click(navigation.getByRole("button", { name: "对话" }));
|
||||
fireEvent.click(navigation.getByRole("button", { name: "监督者" }));
|
||||
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 2));
|
||||
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
|
||||
});
|
||||
|
||||
it("counts only pending supervisor suggestions in the navigation badge", async () => {
|
||||
vi.mocked(api.supervision.suggestions).mockResolvedValue([
|
||||
{ id: "00000000-0000-4000-8000-000000000901", resultId: null, heartbeatRunId: null, scope: { kind: "global" },
|
||||
|
||||
@@ -171,7 +171,6 @@ import {
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import {
|
||||
refreshArtifacts,
|
||||
refreshTasks,
|
||||
useArtifactHydration,
|
||||
useInitialArtifactSync,
|
||||
@@ -3878,8 +3877,8 @@ function App(): React.JSX.Element {
|
||||
}, [loadHeartbeats, projects.length]);
|
||||
|
||||
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
|
||||
await Promise.all([refreshArtifacts(taskStore), refreshHeartbeats()]);
|
||||
}, [refreshHeartbeats, taskStore]);
|
||||
await refreshHeartbeats();
|
||||
}, [refreshHeartbeats]);
|
||||
|
||||
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
|
||||
setHeartbeatLoading(true);
|
||||
|
||||
@@ -152,7 +152,9 @@ function createProps(
|
||||
|
||||
it('graph navigation reopens the graph tab on repeated requests and preserves normal tab changes', async () => {
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never
|
||||
const result = { id: 'A', storyLineId: 'story-A', sourceId: null, summary: 'Saved review', changeDigest: '',
|
||||
createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, openItems: [], timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } }
|
||||
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [result], graph } } as never
|
||||
const props = createProps({ graphNavigation: { resultId: 'A' } })
|
||||
const view = renderComponent(<HeartbeatCenter {...props} />)
|
||||
const tab = screen.getByRole('tab', { name: '故事线图谱' })
|
||||
@@ -236,7 +238,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(within(panel).getByRole('button', { name: t('center.actions.refresh') })).toBeVisible()
|
||||
}
|
||||
if (key === 'supervisor.graph') {
|
||||
const graphNavigation = within(panel).getByRole('tablist', { name: t('supervisor.selection') })
|
||||
const graphNavigation = await within(panel).findByRole('tablist', { name: t('supervisor.selection') })
|
||||
expect(screen.getAllByRole('tablist')).toEqual([navigation, graphNavigation])
|
||||
expect(within(panel).getAllByRole('tablist')).toEqual([graphNavigation])
|
||||
const graphTabs = ['event', 'entity', 'relation'].map((kind) =>
|
||||
@@ -292,11 +294,14 @@ describe('HeartbeatCenter', () => {
|
||||
expect(await screen.findByText('Older review body')).toBeVisible()
|
||||
expect(screen.getByRole('tab', { name: '工作回顾' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(overview).toHaveBeenCalledWith({ resultId: 'old' })
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '在图谱中查看' }))
|
||||
expect(screen.getByRole('tab', { name: '故事线图谱' })).toHaveAttribute('aria-selected', 'true')
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => {
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('edits the supervision timeout and concurrency and preserves failed edits (%s)', async (language) => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
import { useSupervisionStories } from './SupervisionStories'
|
||||
|
||||
const scopeA = { kind: 'projects' as const, projectIds: ['11111111-1111-4111-8111-111111111111'] }
|
||||
const scopeB = { kind: 'projects' as const, projectIds: ['22222222-2222-4222-8222-222222222222'] }
|
||||
const rename = { action: 'rename' as const, storyId: '11111111-1111-4111-8111-111111111111', name: 'Renamed story' }
|
||||
const snapshot = (name: string): SupervisionStoryView => ({ stories: [{ id: rename.storyId, name, projectId: null,
|
||||
projectName: null, parentId: null, level: 'feature', description: '', state: 'active', stateEventId: null,
|
||||
userEdited: false, events: [], startedAt: null, endedAt: null }], experiences: [], unassigned: 0, canUndo: false })
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.goodbuddy = {} as never })
|
||||
|
||||
it.each(['scope', 'refresh revision'] as const)('does not let a retained mutation reload invalidate the new %s read', async change => {
|
||||
const initial = snapshot('Initial'), current = snapshot('Current'), stale = snapshot('Stale mutation reload')
|
||||
let finishMutation!: () => void
|
||||
let finishCurrent!: (value: SupervisionStoryView) => void
|
||||
const storyAction = vi.fn(() => new Promise<void>(resolve => { finishMutation = resolve }))
|
||||
const stories = vi.fn().mockResolvedValueOnce(initial)
|
||||
.mockImplementationOnce(() => new Promise<SupervisionStoryView>(resolve => { finishCurrent = resolve }))
|
||||
.mockResolvedValue(stale)
|
||||
window.goodbuddy = { supervision: { stories, storyAction } } as never
|
||||
const revision = {}
|
||||
const view = renderHook(({ scope, revision }) => useSupervisionStories(scope, true, revision),
|
||||
{ initialProps: { scope: scopeA, revision } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(initial))
|
||||
let mutation!: Promise<boolean>
|
||||
act(() => { mutation = view.result.current.act(rename) })
|
||||
view.rerender({ scope: change === 'scope' ? scopeB : scopeA, revision: change === 'scope' ? revision : {} })
|
||||
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
|
||||
// The old action finishes while the replacement read is still pending.
|
||||
await act(async () => { finishMutation(); await mutation })
|
||||
await act(async () => finishCurrent(current))
|
||||
await waitFor(() => expect(view.result.current.view).toBe(current))
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
expect(view.result.current.error).toBeUndefined()
|
||||
expect(view.result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a retained reload before sending a request after same-scope refresh or unmount', async () => {
|
||||
const current = snapshot('Current')
|
||||
const stories = vi.fn().mockResolvedValue(current)
|
||||
window.goodbuddy = { supervision: { stories } } as never
|
||||
const view = renderHook(({ revision }) => useSupervisionStories(scopeA, true, revision), { initialProps: { revision: 1 } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(current))
|
||||
const obsoleteReload = view.result.current.reload
|
||||
view.rerender({ revision: 2 })
|
||||
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
|
||||
await act(async () => obsoleteReload())
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
const unmountedReload = view.result.current.reload
|
||||
view.unmount()
|
||||
await unmountedReload()
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('still reloads an owned mutation and reuses its refreshed snapshot on tab return', async () => {
|
||||
const initial = snapshot('Initial'), updated = snapshot('Renamed story')
|
||||
const stories = vi.fn().mockResolvedValueOnce(initial).mockResolvedValue(updated)
|
||||
const storyAction = vi.fn().mockResolvedValue(undefined)
|
||||
window.goodbuddy = { supervision: { stories, storyAction } } as never
|
||||
const view = renderHook(({ active }) => useSupervisionStories(scopeA, active, 1), { initialProps: { active: true } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(initial))
|
||||
await act(async () => { expect(await view.result.current.act(rename)).toBe(true) })
|
||||
expect(view.result.current.view).toBe(updated)
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
expect(storyAction).toHaveBeenCalledWith(rename)
|
||||
view.rerender({ active: false })
|
||||
view.rerender({ active: true })
|
||||
expect(view.result.current.view).toBe(updated)
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { SupervisionExperience, SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
@@ -12,20 +12,32 @@ export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | un
|
||||
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 latest = useRef<{ key: string; revision: unknown } | undefined>(undefined)
|
||||
const loaded = useRef<{ key: string; revision: unknown } | undefined>(undefined)
|
||||
const generation = useRef(0)
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { key, revision }
|
||||
return () => { latest.current = undefined }
|
||||
}, [key, revision])
|
||||
const load = useCallback(async () => {
|
||||
if (!api?.stories || !key) return
|
||||
const owner = latest.current
|
||||
// A completed mutation may still hold the previous scope/refresh callback.
|
||||
if (!api?.stories || !key || !owner || owner.key !== key || owner.revision !== revision) return
|
||||
const request = ++generation.current
|
||||
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])
|
||||
if (latest.current === owner && request === generation.current) {
|
||||
loaded.current = { key, revision }
|
||||
setView({ key, data }); setError(undefined)
|
||||
}
|
||||
} catch (reason) { if (latest.current === owner && request === generation.current) setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
}, [api, key, revision])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
if (!active || (loaded.current?.key === key && loaded.current.revision === revision)) return
|
||||
const task = window.setTimeout(() => void load(), 0)
|
||||
return () => window.clearTimeout(task)
|
||||
}, [active, load, revision])
|
||||
const invalidate = () => { generation.current++ }
|
||||
return () => { window.clearTimeout(task); invalidate() }
|
||||
}, [active, key, load, revision])
|
||||
const act = async (action: SupervisionStoryAction) => {
|
||||
if (!api?.storyAction || pending) return false
|
||||
setPending(true)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
import { storyDigest, type StoryDigestEntry } from './supervision-story-digest'
|
||||
import type { StoryDigest, StoryDigestEntry } from './supervision-story-digest'
|
||||
|
||||
type Focus = { kind: 'story' | 'experience'; id: string }
|
||||
const shown = 6
|
||||
@@ -11,15 +10,13 @@ const shown = 6
|
||||
* started, finished or stayed quiet, and which experiences formed or were used. Reads stored
|
||||
* stories only; each item opens the story or experience in the graph.
|
||||
*/
|
||||
export function SupervisionStoryDigest({ view, range, date, onOpen }: {
|
||||
view: SupervisionStoryView
|
||||
range: { from: string; to: string }
|
||||
export function SupervisionStoryDigest({ digest, date, onOpen }: {
|
||||
digest: StoryDigest
|
||||
date: (value: string) => string
|
||||
onOpen: (focus: Focus) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const digest = storyDigest(view, range)
|
||||
const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length
|
||||
const limit = <T,>(key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown)
|
||||
const more = (key: string, count: number) => count > shown && !expanded[key]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionGraphView } from '../../shared/supervision-contracts'
|
||||
import { useListWindow } from './use-list-window'
|
||||
|
||||
type Kind = 'event' | 'entity' | 'relation'
|
||||
|
||||
export function SupervisorSelectionList({ kind, events, entities, relations, entityMap, selectedId, onSelect, date, shortDate, entityTone, id, labelledBy }: {
|
||||
kind: Kind
|
||||
events: SupervisionGraphView['events']
|
||||
entities: SupervisionGraphView['entities']
|
||||
relations: SupervisionGraphView['relations']
|
||||
entityMap: Map<string, SupervisionGraphView['entities'][number]>
|
||||
selectedId?: string
|
||||
onSelect: (selection: { kind: Kind; id: string }) => void
|
||||
date: (value: string) => string
|
||||
shortDate: (value: string) => string
|
||||
entityTone: (id: string) => number
|
||||
id: string
|
||||
labelledBy: string
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [keyboardId, setKeyboardId] = useState<string>()
|
||||
const records = kind === 'event' ? events : kind === 'entity' ? entities : relations
|
||||
const ids = useMemo(() => records.map(record => record.id), [records])
|
||||
// Native Tab entry from either side must reach the logical endpoints, even when scrolled away.
|
||||
const list = useListWindow({ ids, scope: kind, scrollRef: ref, keepIds: [ids[0], ids.at(-1), selectedId, keyboardId], enabled: true, estimatedRowHeight: kind === 'event' ? 64 : 44 })
|
||||
const { onScroll } = list
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = ref.current
|
||||
if (!panel || !selectedId) return
|
||||
const row = Array.from(panel.querySelectorAll<HTMLElement>('[data-list-window-row]')).find(item => item.dataset.listWindowRow === selectedId)
|
||||
if (!row) return
|
||||
const bounds = panel.getBoundingClientRect(), rect = row.getBoundingClientRect()
|
||||
if (rect.top < bounds.top) panel.scrollTop += rect.top - bounds.top
|
||||
else if (rect.bottom > bounds.bottom) panel.scrollTop += rect.bottom - bounds.bottom
|
||||
onScroll()
|
||||
}, [selectedId, onScroll])
|
||||
|
||||
return <div ref={ref} className="supervisor-workspace__list-panel supervisor-workspace__selection-list"
|
||||
role="tabpanel" id={id} aria-labelledby={labelledBy} tabIndex={0}
|
||||
onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}
|
||||
onKeyDown={event => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab'].includes(event.key) || !ids.length) return
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>('[data-list-window-row]')
|
||||
const index = row ? ids.indexOf(row.dataset.listWindowRow!) : -1
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1
|
||||
: index + (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1)
|
||||
if (event.key === 'Tab' && (index < 0 || next < 0 || next >= ids.length)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const nextId = ids[Math.max(0, Math.min(ids.length - 1, next))]!
|
||||
// Materialize the logical next row before native focus, including across spacers.
|
||||
flushSync(() => setKeyboardId(nextId))
|
||||
const target = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? []).find(item => item.dataset.listWindowRow === nextId)
|
||||
target?.querySelector('button')?.focus()
|
||||
target?.scrollIntoView?.({ block: 'nearest' })
|
||||
list.onScroll()
|
||||
}}>
|
||||
{!ids.length && <p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>}
|
||||
{list.segments.map(segment => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height, flex: '0 0 auto' }} />
|
||||
const index = segment.index, rowId = ids[index]!
|
||||
const event = kind === 'event' ? events[index] : undefined
|
||||
const entity = kind === 'entity' ? entities[index] : undefined
|
||||
const relation = kind === 'relation' ? relations[index] : undefined
|
||||
return <div key={rowId} data-list-window-row={rowId} ref={list.rowRef(rowId)} className="supervisor-workspace__list-row">
|
||||
<button type="button" aria-pressed={selectedId === rowId}
|
||||
aria-label={event ? `${index + 1}. ${event.title} · ${date(event.occurred_at)}` : undefined}
|
||||
data-tone={entity ? entityTone(rowId) : undefined} onClick={() => onSelect({ kind, id: rowId })}>
|
||||
{event ? <><span className="supervisor-workspace__event-index">{index + 1}.</span>
|
||||
<span className="supervisor-workspace__list-copy">{event.title}<small title={date(event.occurred_at)}>{shortDate(event.occurred_at)}</small></span></>
|
||||
: entity ? <><i className="supervisor-workspace__entity-dot" aria-hidden="true" /><span className="supervisor-workspace__list-copy">{entity.canonical_label}</span></>
|
||||
: relation ? <>{entityMap.get(relation.from_entity_id)?.canonical_label} → {entityMap.get(relation.to_entity_id)?.canonical_label} · {t(`supervisor.relationTypes.${relation.relation_type}`, { defaultValue: relation.relation_type })}</> : null}
|
||||
</button>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SupervisorWorkspace } from './SupervisorWorkspace'
|
||||
import { changeUiLocale, i18nResources } from './i18n'
|
||||
import * as digestModule from './supervision-story-digest'
|
||||
|
||||
const result = { id: 'result-1', storyLineId: 'story', sourceId: null, summary: 'Recap', changeDigest: '', createdAt: '2026-09-22T00:00:00.000Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }, openItems: [] }
|
||||
const render = (ui: React.ReactNode) => {
|
||||
@@ -15,6 +16,143 @@ const selectHistory = (id: string) => {
|
||||
}
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it('loads recap independently of graph and reuses the graph across recap switches until refreshed', async () => {
|
||||
const data = { storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }
|
||||
const graph = vi.fn().mockResolvedValue(data)
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByText('Recap')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
expect(screen.getByRole('group', { name: '故事线图谱' })).toBeVisible()
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('computes one digest per story snapshot and keeps it on warm switches and unrelated edits', async () => {
|
||||
const snapshot = { stories: [{ id: 's', name: 'Story', projectId: null, projectName: null, parentId: null, level: 'feature',
|
||||
description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: result.createdAt, endedAt: null,
|
||||
events: [{ id: 'e', title: 'Evidence', projectId: null, startedAt: result.createdAt, endedAt: result.createdAt, primary: true, userSet: false }] }],
|
||||
experiences: [], unassigned: 0, canUndo: false }
|
||||
const stories = vi.fn().mockResolvedValue(snapshot)
|
||||
const digest = vi.spyOn(digestModule, 'storyDigest')
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], stories,
|
||||
graph: async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByRole('article', { name: '按故事查看本次回顾' })
|
||||
expect(digest.mock.calls.filter(([data]) => data === snapshot)).toHaveLength(1)
|
||||
digest.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(stories).toHaveBeenCalledOnce()
|
||||
expect(digest).not.toHaveBeenCalled()
|
||||
digest.mockRestore()
|
||||
})
|
||||
|
||||
it('bounds graph list rows while keeping the last record and keyboard traversal reachable', async () => {
|
||||
const events = Array.from({ length: 600 }, (_, i) => ({ id: `e${i}`, title: `Event ${i}`, description: `Detail ${i}`, occurred_at: result.createdAt }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result],
|
||||
graph: async () => ({ storyLine: null, events, entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
const panel = screen.getByRole('tabpanel')
|
||||
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(122)
|
||||
fireEvent.keyDown(panel, { key: 'End' })
|
||||
expect(within(panel).getByRole('button', { name: /600\. Event 599/ })).toHaveFocus()
|
||||
fireEvent.click(document.activeElement!)
|
||||
expect(screen.getByText('Detail 599')).toBeVisible()
|
||||
fireEvent.keyDown(panel, { key: 'Home' })
|
||||
expect(within(panel).getByRole('button', { name: /1\. Event 0 / })).toHaveFocus()
|
||||
for (let i = 0; i < 125; i++) fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
|
||||
expect(within(panel).getByRole('button', { name: /126\. Event 125/ })).toHaveFocus()
|
||||
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(123)
|
||||
})
|
||||
|
||||
it('leaves a pending or failed graph without blocking recap and retries only on graph demand', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
const graphData = (title: string) => ({ storyLine: null, events: [{ id: 'e', title, description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
|
||||
const graph = vi.fn().mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
.mockRejectedValueOnce(new Error('graph failed')).mockResolvedValue(graphData('Current event'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByText('Recap')
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledOnce())
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(screen.getByLabelText('历史结果')).toBeEnabled()
|
||||
await act(async () => finish(graphData('Obsolete event')))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Recap')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(graph).toHaveBeenCalledTimes(3)
|
||||
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing historical recap navigation retryable without falling back to the latest result', async () => {
|
||||
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
|
||||
const graph = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview, graph } } as never
|
||||
render(<SupervisorWorkspace graphNavigation={{ resultId: 'deleted' }} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
|
||||
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'deleted')).toHaveLength(2))
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates a cached graph after entity edits and keeps source bodies demand-loaded', async () => {
|
||||
let confirmed = false
|
||||
const graph = vi.fn(async () => ({ storyLine: null,
|
||||
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: confirmed ? 'confirmed' : 'automatic' }],
|
||||
relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
|
||||
eventEntities: [{ event_id: 'event', entity_id: 'entity' }], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
|
||||
const entityAction = vi.fn(async () => { confirmed = true })
|
||||
const source = vi.fn().mockResolvedValue({ title: 'Source', content: 'Full saved body', occurredAt: result.createdAt })
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, entityAction } } as never
|
||||
const view = render(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(source).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('tab', { name: /实体/ }))
|
||||
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: 'Entity' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
|
||||
await waitFor(() => {
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('历史结果')).toBeEnabled()
|
||||
})
|
||||
expect(entityAction).toHaveBeenCalledWith({ entityId: 'entity', resultId: result.id, action: 'confirm' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
|
||||
expect(await screen.findByText('Full saved body')).toBeVisible()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('Full saved body')).toBeVisible()
|
||||
expect(source).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
|
||||
let finishReview!: () => void
|
||||
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
|
||||
@@ -339,7 +477,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(screen.getByLabelText('时间范围')).toHaveValue('30')
|
||||
expect(screen.getByLabelText('关注范围')).toHaveValue(project.id)
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
@@ -421,7 +559,7 @@ describe('SupervisorWorkspace', () => {
|
||||
let resolveSource!: (value: unknown) => void
|
||||
const source = vi.fn(() => new Promise(resolve => { resolveSource = resolve }))
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }], entities: [], relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }], eventEntities: [], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, source } } as never
|
||||
window.goodbuddy = { supervision: { overview: async () => [{ ...result, id: 'A' }, { ...result, id: 'B' }], graph, source } } as never
|
||||
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'A' }} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
|
||||
@@ -450,6 +588,9 @@ describe('SupervisorWorkspace', () => {
|
||||
view.rerender(<SupervisorWorkspace graphNavigation={next} />)
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue('B')
|
||||
selectHistory('A')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={next} />)
|
||||
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' }))
|
||||
@@ -458,14 +599,16 @@ describe('SupervisorWorkspace', () => {
|
||||
|
||||
it('graph navigation requests results outside overview and keeps missing results retryable without latest fallback', async () => {
|
||||
const graph = vi.fn().mockRejectedValue(new Error('Missing requested result'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
|
||||
window.goodbuddy = { supervision: { overview, graph } } as never
|
||||
render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'older' }} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
|
||||
expect(screen.queryByText('Missing requested result')).not.toBeInTheDocument()
|
||||
expect(graph).toHaveBeenCalledWith({ resultId: 'older', storyLineId: undefined })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
expect(overview).toHaveBeenCalledWith({ resultId: 'older' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'older', storyLineId: undefined })
|
||||
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'older')).toHaveLength(2))
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
|
||||
})
|
||||
it('opens the selected result graph and ignores a late response from another project', async () => {
|
||||
@@ -473,15 +616,18 @@ describe('SupervisorWorkspace', () => {
|
||||
const b = { ...result, id: 'B', storyLineId: 'story-B', summary: 'B recap' }
|
||||
const graphFor = (title: string) => ({ storyLine: null, events: [{ id: title, title, description: title, occurred_at: result.createdAt }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
|
||||
let resolveA!: (value: unknown) => void
|
||||
const graph = vi.fn().mockResolvedValueOnce(graphFor('B event')).mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
|
||||
const graph = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [b, a], graph } } as never
|
||||
const onTabChange = vi.fn()
|
||||
const view = render(<SupervisorWorkspace onTabChange={onTabChange} />)
|
||||
await screen.findByText('B recap')
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
selectHistory('A')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace tab="graph" onTabChange={onTabChange} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story-A' }))
|
||||
const navigation = { resultId: 'B' }
|
||||
view.rerender(<SupervisorWorkspace graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story-B' }))
|
||||
await act(async () => resolveA(graphFor('Wrong A event')))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
@@ -495,7 +641,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(tokens, token).toContain(`${token}:`)
|
||||
}
|
||||
})
|
||||
afterEach(async () => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
|
||||
afterEach(async () => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
|
||||
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
|
||||
window.goodbuddy = {} as never
|
||||
|
||||
@@ -849,7 +995,8 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(circles).toHaveLength(8)
|
||||
expect(new Set(circles.map(circle => `${circle.getAttribute('cx')},${circle.getAttribute('cy')}`)).size).toBe(8)
|
||||
const list = screen.getByRole('complementary', { name: '图谱选择' })
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(80)
|
||||
expect(within(list).getAllByRole('button').length).toBeLessThan(80)
|
||||
fireEvent.keyDown(within(list).getByRole('tabpanel'), { key: 'End' })
|
||||
fireEvent.click(within(list).getByRole('button', { name: /80\. 事件 79/ }))
|
||||
expect(within(graph).getByRole('button', { name: '事件 79' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(screen.getByRole('complementary', { name: '详情与来源' })).getByText('详情 79')).toBeInTheDocument()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { storyDigest } from './supervision-story-digest'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
|
||||
import { useFillHeight } from './use-fill-height'
|
||||
import { SupervisorSelectionList } from './SupervisorSelectionList'
|
||||
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
|
||||
@@ -81,8 +82,10 @@ export function SupervisorWorkspace({
|
||||
const api = window.goodbuddy?.supervision
|
||||
const [results, setResults] = useState<SupervisionResultView[]>([])
|
||||
const [graph, setGraph] = useState(emptyGraph)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string>()
|
||||
const [overviewLoading, setOverviewLoading] = useState(true)
|
||||
const [overviewError, setOverviewError] = useState<string>()
|
||||
const [graphResultId, setGraphResultId] = useState<string>()
|
||||
const [graphError, setGraphError] = useState<string>()
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
|
||||
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
|
||||
@@ -122,26 +125,29 @@ export function SupervisorWorkspace({
|
||||
setAppliedNavigation(graphNavigation)
|
||||
setResultId(graphNavigation?.resultId)
|
||||
setGraph(emptyGraph)
|
||||
setLoading(true)
|
||||
setGraphResultId(undefined)
|
||||
setGraphError(undefined)
|
||||
setOverviewLoading(true)
|
||||
setSource(undefined)
|
||||
setSelection(graphNavigation?.focus)
|
||||
setDetailOpen(Boolean(graphNavigation?.focus))
|
||||
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
|
||||
setPending(undefined)
|
||||
setLoadError(undefined)
|
||||
setOverviewError(undefined)
|
||||
}
|
||||
const selectedResult = useRef<string | undefined>(undefined)
|
||||
const requestedNavigation = useRef<SupervisionGraphNavigation | undefined>(undefined)
|
||||
const loadGeneration = useRef(0)
|
||||
const [confirmRemoval, setConfirmRemoval] = useState(false)
|
||||
const [revision, setRevision] = useState<string>()
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(i18n.resolvedLanguage)
|
||||
const shortDate = (value: string) =>
|
||||
new Date(value).toLocaleDateString(i18n.resolvedLanguage, {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'
|
||||
}), [i18n.resolvedLanguage])
|
||||
const shortDateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
|
||||
month: '2-digit', day: '2-digit'
|
||||
}), [i18n.resolvedLanguage])
|
||||
const date = (value: string) => dateFormatter.format(new Date(value))
|
||||
const shortDate = (value: string) => shortDateFormatter.format(new Date(value))
|
||||
const scopeText = (scope: SupervisionRunRequest['scope']) =>
|
||||
scope.kind === 'global'
|
||||
? t('center.scope.global')
|
||||
@@ -160,13 +166,15 @@ export function SupervisorWorkspace({
|
||||
const generation = ++loadGeneration.current
|
||||
setPending(undefined)
|
||||
selectedResult.current = requestedId
|
||||
setLoading(true)
|
||||
setOverviewLoading(true)
|
||||
setGraph(emptyGraph)
|
||||
setGraphResultId(undefined)
|
||||
setGraphError(undefined)
|
||||
setResultId(requestedId)
|
||||
setSource(undefined)
|
||||
setConfirmRemoval(false)
|
||||
setRevision(undefined)
|
||||
setLoadError(undefined)
|
||||
setOverviewError(undefined)
|
||||
try {
|
||||
const overview = (await api.overview()).map((item) => supervisionResultViewSchema.parse(item))
|
||||
if (generation !== loadGeneration.current) return
|
||||
@@ -177,35 +185,15 @@ export function SupervisorWorkspace({
|
||||
}
|
||||
const selected = requestedId ? overview.find((item) => item.id === requestedId) : overview[0]
|
||||
setResults(overview)
|
||||
if (requestedId && !selected) throw new Error('Requested supervision result is unavailable')
|
||||
const nextId = requestedId ?? selected?.id
|
||||
setResultId(nextId)
|
||||
selectedResult.current = nextId
|
||||
const nextGraph = nextId ? await api.graph({ resultId: nextId, storyLineId: selected?.storyLineId }) : emptyGraph
|
||||
if (generation !== loadGeneration.current) return
|
||||
const parsedGraph = supervisionGraphViewSchema.parse(nextGraph)
|
||||
if (parsedGraph.storyLine)
|
||||
heartbeatScopeSchema.parse(JSON.parse(parsedGraph.storyLine.scope_json))
|
||||
setGraph(parsedGraph)
|
||||
setSelection((current) => {
|
||||
const records =
|
||||
current?.kind === 'event'
|
||||
? parsedGraph.events
|
||||
: current?.kind === 'entity'
|
||||
? parsedGraph.entities
|
||||
: parsedGraph.relations
|
||||
if (current?.kind === 'story' || current?.kind === 'experience') return current
|
||||
if (current && records.some((item) => item.id === current.id))
|
||||
return current
|
||||
const latestEvent = [...parsedGraph.events].sort(
|
||||
(a, b) => Date.parse(b.occurred_at) - Date.parse(a.occurred_at)
|
||||
)[0]
|
||||
return latestEvent ? { kind: 'event', id: latestEvent.id } : undefined
|
||||
})
|
||||
} catch {
|
||||
if (generation !== loadGeneration.current) return
|
||||
setLoadError(t('supervisor.loadFailed'))
|
||||
setOverviewError(t('supervisor.loadFailed'))
|
||||
} finally {
|
||||
if (generation === loadGeneration.current) setLoading(false)
|
||||
if (generation === loadGeneration.current) setOverviewLoading(false)
|
||||
}
|
||||
}, [api, t])
|
||||
useEffect(() => {
|
||||
@@ -219,6 +207,36 @@ export function SupervisorWorkspace({
|
||||
return () => { window.clearTimeout(task); invalidate() }
|
||||
}, [refresh, graphNavigation])
|
||||
|
||||
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
|
||||
const storyLineId = latest?.storyLineId
|
||||
// The selected result is the only graph cache. Refresh/navigation invalidate it;
|
||||
// switching tabs keeps it, and an obsolete in-flight read cannot refill it.
|
||||
useEffect(() => {
|
||||
if (!api || tab !== 'graph' || overviewLoading || overviewError || !resultId || graphResultId === resultId || graphError) return
|
||||
let active = true
|
||||
const generation = loadGeneration.current
|
||||
void api.graph({ resultId, storyLineId }).then(value => {
|
||||
if (!active || generation !== loadGeneration.current) return
|
||||
const parsed = supervisionGraphViewSchema.parse(value)
|
||||
if (parsed.storyLine) heartbeatScopeSchema.parse(JSON.parse(parsed.storyLine.scope_json))
|
||||
setGraph(parsed)
|
||||
setGraphResultId(resultId)
|
||||
setSelection(current => {
|
||||
if (current?.kind === 'story' || current?.kind === 'experience') return current
|
||||
const records = current?.kind === 'event' ? parsed.events : current?.kind === 'entity' ? parsed.entities : parsed.relations
|
||||
if (current && records.some(item => item.id === current.id)) return current
|
||||
const newest = parsed.events.reduce<SupervisionGraphView['events'][number] | undefined>((found, event) =>
|
||||
!found || Date.parse(event.occurred_at) > Date.parse(found.occurred_at) ? event : found, undefined)
|
||||
return newest ? { kind: 'event', id: newest.id } : undefined
|
||||
})
|
||||
}).catch(() => {
|
||||
if (active && generation === loadGeneration.current) setGraphError(t('supervisor.loadFailed'))
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [api, tab, overviewLoading, overviewError, resultId, storyLineId, graphResultId, graphError, t])
|
||||
const loadError = overviewError ?? (tab === 'graph' ? graphError : undefined)
|
||||
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
|
||||
|
||||
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
|
||||
if (period === 'range') {
|
||||
const from = new Date(`${startDate}T00:00:00`)
|
||||
@@ -510,11 +528,10 @@ export function SupervisorWorkspace({
|
||||
if (generation === loadGeneration.current) setPending(undefined)
|
||||
}
|
||||
}
|
||||
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
|
||||
const graphScope = graph.storyLine
|
||||
const graphScope = useMemo(() => graph.storyLine
|
||||
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
|
||||
: undefined
|
||||
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
|
||||
: undefined, [graph.storyLine])
|
||||
const storyState = useSupervisionStories(latest?.scope ?? graphScope, tab === 'graph' || tab === 'overview', results)
|
||||
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
|
||||
const graphLayoutRef = useRef<HTMLDivElement>(null)
|
||||
// Flat labels need a taller minimum; the spiral can use short windows without that constraint.
|
||||
@@ -523,8 +540,8 @@ export function SupervisorWorkspace({
|
||||
// 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 digest = useMemo(() => latest && storyState.available ? storyDigest(storyState.view, latest.timeRange) : undefined,
|
||||
[latest, storyState.available, storyState.view])
|
||||
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
|
||||
@@ -713,7 +730,7 @@ export function SupervisorWorkspace({
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{hasDigest && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
|
||||
{hasDigest && <SupervisionStoryDigest digest={digest} date={date}
|
||||
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
|
||||
</aside>}
|
||||
</article>
|
||||
@@ -774,7 +791,11 @@ export function SupervisorWorkspace({
|
||||
{ id: 'experience' as const, label: `${t('supervisor.listTabs.experience')} ${storyState.view.experiences.length}` }] : [])
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
{listTab === 'event' || listTab === 'entity' || listTab === 'relation' ? <SupervisorSelectionList
|
||||
key={`${resultId}:${listTab}`} kind={listTab} events={layout.events} entities={layout.entities} relations={graph.relations}
|
||||
entityMap={layout.entityMap} selectedId={selection?.kind === listTab ? selection.id : undefined}
|
||||
onSelect={select} date={date} shortDate={shortDate} entityTone={entityTone}
|
||||
id={`${graphId}-list-panel-${listTab}`} labelledBy={`${graphId}-list-tab-${listTab}`} /> : <div
|
||||
key={listTab}
|
||||
className="supervisor-workspace__list-panel"
|
||||
role="tabpanel"
|
||||
@@ -782,10 +803,6 @@ export function SupervisorWorkspace({
|
||||
aria-labelledby={`${graphId}-list-tab-${listTab}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{((listTab === 'entity' && !layout.entities.length) ||
|
||||
(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>}
|
||||
@@ -800,67 +817,7 @@ export function SupervisorWorkspace({
|
||||
<SupervisionExperienceList experiences={storyState.view.experiences} selectedId={selectedExperience?.id}
|
||||
onSelect={(id) => select({ kind: 'experience', id })} />
|
||||
</>}
|
||||
{listTab === 'event' && layout.events.map((event, index) => (
|
||||
<button
|
||||
key={event.id}
|
||||
aria-label={`${index + 1}. ${event.title} · ${date(event.occurred_at)}`}
|
||||
aria-pressed={selection?.id === event.id}
|
||||
onClick={() => select({ kind: 'event', id: event.id })}
|
||||
>
|
||||
<span className="supervisor-workspace__event-index">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="supervisor-workspace__list-copy">
|
||||
{event.title}
|
||||
<small title={date(event.occurred_at)}>
|
||||
{shortDate(event.occurred_at)}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{listTab === 'entity' && layout.entities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
data-tone={entityTone(entity.id)}
|
||||
aria-pressed={selection?.id === entity.id}
|
||||
onClick={() =>
|
||||
select({ kind: 'entity', id: entity.id })
|
||||
}
|
||||
>
|
||||
<i
|
||||
className="supervisor-workspace__entity-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="supervisor-workspace__list-copy">
|
||||
{entity.canonical_label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{listTab === 'relation' && graph.relations.map((relation) => (
|
||||
<button
|
||||
key={relation.id}
|
||||
aria-pressed={selection?.id === relation.id}
|
||||
onClick={() =>
|
||||
select({ kind: 'relation', id: relation.id })
|
||||
}
|
||||
>
|
||||
{
|
||||
layout.entityMap.get(relation.from_entity_id)
|
||||
?.canonical_label
|
||||
}{' '}
|
||||
→{' '}
|
||||
{
|
||||
layout.entityMap.get(relation.to_entity_id)
|
||||
?.canonical_label
|
||||
}{' '}
|
||||
·{' '}
|
||||
{t(
|
||||
`supervisor.relationTypes.${relation.relation_type}`,
|
||||
{ defaultValue: relation.relation_type }
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
<p className="supervisor-workspace__muted">
|
||||
{t('supervisor.legend')}
|
||||
</p>
|
||||
|
||||
@@ -39,3 +39,17 @@ it('lists experiences formed in the period, or applied in it when formed earlier
|
||||
expect(storyDigest(view, range).experiences.map(entry => [entry.experience.id, entry.role, entry.events.map(item => item.id)]))
|
||||
.toEqual([['new', 'formed', ['a']], ['reused', 'applied', ['c']]])
|
||||
})
|
||||
|
||||
it('preserves inclusive boundaries, tie ordering, orphan threads and direct-child aggregation', () => {
|
||||
const view: SupervisionStoryView = { unassigned: 0, canUndo: false, experiences: [], stories: [
|
||||
story('feature', [event('b', range.from), event('excluded', range.from, false)]),
|
||||
story('child', [event('a', range.from), event('z', range.to)], { level: 'thread', parentId: 'feature' }),
|
||||
story('grandchild', [event('deep', range.from)], { level: 'thread', parentId: 'child' }),
|
||||
story('orphan', [event('orphan-event', range.to)], { level: 'thread', parentId: 'missing' }),
|
||||
story('cross', [event('linked', range.from, false)], { level: 'cross' })
|
||||
] }
|
||||
const digest = storyDigest(view, range)
|
||||
expect(digest.started.map(entry => [entry.story.id, entry.events.map(item => item.id), entry.latest.id]))
|
||||
.toEqual([['feature', ['a', 'b', 'z'], 'z'], ['orphan', ['orphan-event'], 'orphan-event'], ['cross', ['linked'], 'linked']])
|
||||
expect(view.stories[0]!.events.map(item => item.id)).toEqual(['b', 'excluded'])
|
||||
})
|
||||
|
||||
@@ -28,10 +28,17 @@ export function storyDigest(view: SupervisionStoryView, range: { from: string; t
|
||||
const inRange = (value: string) => { const at = Date.parse(value); return at >= from && at <= to }
|
||||
const digest: StoryDigest = { advanced: [], started: [], concluded: [], quiet: [], experiences: [], unassigned: view.unassigned }
|
||||
// Features show their threads' work too; threads are listed on their own only when the feature is absent.
|
||||
const children = (story: SupervisionStory) => view.stories.filter(child => child.parentId === story.id)
|
||||
const ids = new Set(view.stories.map(story => story.id))
|
||||
const children = new Map<string, SupervisionStory[]>()
|
||||
for (const story of view.stories) {
|
||||
if (story.level === 'thread' && view.stories.some(parent => parent.id === story.parentId)) continue
|
||||
const events = [...own(story), ...children(story).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
|
||||
if (story.parentId === null) continue
|
||||
const siblings = children.get(story.parentId)
|
||||
if (siblings) siblings.push(story)
|
||||
else children.set(story.parentId, [story])
|
||||
}
|
||||
for (const story of view.stories) {
|
||||
if (story.level === 'thread' && story.parentId !== null && ids.has(story.parentId)) continue
|
||||
const events = [...own(story), ...(children.get(story.id) ?? []).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
|
||||
if (!events.length) continue
|
||||
const during = events.filter(event => inRange(event.startedAt))
|
||||
const before = events.filter(event => Date.parse(event.startedAt) < from)
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button,
|
||||
.supervisor-workspace__list-row > button,
|
||||
.supervisor-workspace__story-group button {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@@ -810,10 +811,12 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
.supervisor-workspace__list-panel > button:hover,
|
||||
.supervisor-workspace__list-row > button:hover,
|
||||
.supervisor-workspace__story-group button:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button[aria-pressed='true'],
|
||||
.supervisor-workspace__list-row > button[aria-pressed='true'],
|
||||
.supervisor-workspace__story-group button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
@@ -976,6 +979,17 @@
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.supervisor-workspace__graph-list > .supervisor-workspace__selection-list {
|
||||
gap: 0;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
.supervisor-workspace__list-row {
|
||||
flex: 0 0 auto;
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-row > button {
|
||||
width: 100%;
|
||||
}
|
||||
.supervisor-activity > .supervisor-workspace__action-bar {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { installFakeListLayout } from './list-window-test-layout'
|
||||
import { useListWindow } from './use-list-window'
|
||||
|
||||
const ids = Array.from({ length: 600 }, (_, index) => `row-${index}`)
|
||||
function List() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const list = useListWindow({ ids, scope: 'test', scrollRef: ref, enabled: true, keepIds: ['row-599'], estimatedRowHeight: 44 })
|
||||
return <div ref={ref} data-scroll-list onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}>
|
||||
{list.segments.map(segment => segment.kind === 'spacer'
|
||||
? <div key={segment.key} data-list-window-spacer style={{ height: segment.height }} />
|
||||
: <div key={ids[segment.index]} data-list-window-row={ids[segment.index]} ref={list.rowRef(ids[segment.index]!)}><button>{ids[segment.index]}</button></div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps visibly focused rows in view when rewindowing replaces measured DOM with estimated spacers', () => {
|
||||
const layout = installFakeListLayout({ isContainer: element => element.hasAttribute('data-scroll-list'),
|
||||
rowAttribute: 'data-list-window-row', rowHeight: () => 38, viewport: 528 })
|
||||
try {
|
||||
const view = render(<List />)
|
||||
const panel = view.container.querySelector<HTMLElement>('[data-scroll-list]')!
|
||||
const row = panel.querySelector<HTMLElement>('[data-list-window-row="row-599"]')!
|
||||
const button = row.querySelector('button')!
|
||||
act(() => { button.focus(); panel.scrollTop = panel.scrollHeight; fireEvent.scroll(panel) })
|
||||
expect(button).toHaveFocus()
|
||||
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
|
||||
layout.measure()
|
||||
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
|
||||
// Explicit scrolling away keeps focus mounted, but must not pull the viewport back.
|
||||
layout.scroll(panel, 0)
|
||||
expect(button).toHaveFocus()
|
||||
expect(panel.scrollTop).toBe(0)
|
||||
expect(row.getBoundingClientRect().top).toBeGreaterThan(528)
|
||||
} finally { layout.restore() }
|
||||
})
|
||||
@@ -184,6 +184,7 @@ export function useListWindow({
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined)
|
||||
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
|
||||
const visibleFocusRef = useRef<HTMLElement | undefined>(undefined)
|
||||
// List-relative scroll position seen at the last scroll event or commit, and
|
||||
// the ids and offsets of the last committed windowed layout. Together they
|
||||
// locate the reader's row when ids change, without reading layout per scroll.
|
||||
@@ -262,9 +263,23 @@ export function useListWindow({
|
||||
return { top, viewport: container ? container.clientHeight : window.innerHeight }
|
||||
}, [])
|
||||
|
||||
const captureVisibleFocus = useCallback((current: Geometry): void => {
|
||||
visibleFocusRef.current = undefined
|
||||
if (current.list.contains(document.activeElement)) {
|
||||
const focused = document.activeElement?.closest<HTMLElement>(rowSelector)
|
||||
if (focused) {
|
||||
const rect = focused.getBoundingClientRect()
|
||||
const top = viewportTop(current.container)
|
||||
const height = current.container?.clientHeight ?? window.innerHeight
|
||||
if (rect.top >= top - 1 && rect.bottom <= top + height + 1) visibleFocusRef.current = focused
|
||||
}
|
||||
}
|
||||
}, [rowSelector])
|
||||
|
||||
/** First mounted row reaching into the viewport, relative to the viewport top. */
|
||||
const captureScrollAnchor = useCallback((): void => {
|
||||
const current = geometry()
|
||||
if (current) captureVisibleFocus(current)
|
||||
if (!current || position(current).top <= 0) {
|
||||
scrollAnchorRef.current = undefined
|
||||
return
|
||||
@@ -278,7 +293,7 @@ export function useListWindow({
|
||||
}
|
||||
}
|
||||
scrollAnchorRef.current = undefined
|
||||
}, [geometry, position, rowAttribute, rowSelector])
|
||||
}, [captureVisibleFocus, geometry, position, rowAttribute, rowSelector])
|
||||
|
||||
const activeRef = useRef(active)
|
||||
useLayoutEffect(() => {
|
||||
@@ -295,8 +310,11 @@ export function useListWindow({
|
||||
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
if (!coversViewport(layoutNow, top)) setAnchorTop(top)
|
||||
}, [geometry, heights, position])
|
||||
if (!coversViewport(layoutNow, top)) {
|
||||
captureVisibleFocus(current)
|
||||
setAnchorTop(top)
|
||||
}
|
||||
}, [captureVisibleFocus, geometry, heights, position])
|
||||
|
||||
// Row and viewport measurement.
|
||||
useLayoutEffect(() => {
|
||||
@@ -391,6 +409,20 @@ export function useListWindow({
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
// Height estimates can move a just-focused row past the viewport edge after
|
||||
// keyboard navigation. Only follow focus that was visible before remeasurement.
|
||||
const focused = visibleFocusRef.current
|
||||
visibleFocusRef.current = undefined
|
||||
if (windowed && focused?.isConnected && focused.contains(document.activeElement)) {
|
||||
const rect = focused.getBoundingClientRect()
|
||||
const top = viewportTop(container)
|
||||
const height = container?.clientHeight ?? window.innerHeight
|
||||
const delta = rect.bottom > top + height ? rect.bottom - top - height : Math.min(0, rect.top - top)
|
||||
if (Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
const layoutNow = layoutRef.current
|
||||
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
|
||||
const { top } = position(current)
|
||||
|
||||
Reference in New Issue
Block a user