fix: keep supervisor controls usable and prevent overlapping reviews

Allow only one active review across manual starts, automatic runs and resume. Add cancellation that holds admission through runtime cleanup, preserves saved batches and prevents cancelled runs from resuming.

Keep review controls usable during execution, show a dismissible progress notice and remove redundant filter labels. Include focused regressions and lifecycle documentation.

Release note: Supervision reviews can now be cancelled from Activity records. Starting another review shows the active review instead of duplicating work, while scope, time and refresh controls remain available.
This commit is contained in:
mesalogo
2026-09-26 15:55:50 +08:00
parent 0759d5dcff
commit 5116613615
30 changed files with 653 additions and 92 deletions
+2
View File
@@ -691,6 +691,8 @@ const api: DesktopApi = {
history: vi.fn(async () => ({ runs: [], entries: [] })),
},
supervision: {
cancel: vi.fn(async () => undefined),
execution: vi.fn(async () => ({ active: false })),
pause: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
batches: vi.fn(async () => []),
+4 -2
View File
@@ -151,7 +151,7 @@ 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: { overview: async () => [], graph } } as never
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never
const props = createProps({ graphNavigation: { resultId: 'A' } })
const view = renderComponent(<HeartbeatCenter {...props} />)
const tab = screen.getByRole('tab', { name: '故事线图谱' })
@@ -171,6 +171,7 @@ it('graph navigation reopens the graph tab on repeated requests and preserves no
describe('HeartbeatCenter', () => {
beforeEach(() => {
vi.stubGlobal('goodbuddy', { supervision: {
execution: vi.fn(async () => ({ active: false })),
overview: vi.fn(async () => []),
graph: vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
} })
@@ -189,6 +190,7 @@ describe('HeartbeatCenter', () => {
await i18n.changeLanguage(language)
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
vi.stubGlobal('goodbuddy', { supervision: {
execution: vi.fn(async () => ({ active: false })),
overview: vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Review summary', changeDigest: '', createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]),
activity: vi.fn(async () => []),
graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: JSON.stringify({ kind: 'global' }) }, events: [
@@ -242,7 +244,7 @@ describe('HeartbeatCenter', () => {
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId === 'old' ? [old] : [])
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
const activity = vi.fn(async () => [{ id: 'run', kind: 'supervision', trigger: 'manual', status: 'completed', scope: old.scope, startedAt: old.createdAt, completedAt: old.createdAt, timeRange: old.timeRange, error: null, summary: 'Activity summary', resultId: 'old', heartbeatStatus: null, supervisionStatus: 'completed' }])
vi.stubGlobal('goodbuddy', { supervision: { overview, graph, activity } })
vi.stubGlobal('goodbuddy', { supervision: { execution: async () => ({ active: false }), overview, graph, activity } })
renderComponent(<HeartbeatCenter {...createProps()} />)
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
+3 -3
View File
@@ -3,7 +3,7 @@ import type { SupervisionActivity } from '../../shared/supervision-contracts'
const phases = ['collecting', 'extracting', 'summarizing', 'saving'] as const
export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
export function SupervisionReviewStages({ row, stopping }: { row: SupervisionActivity; stopping?: 'paused' | 'cancelled' }) {
const { t } = useTranslation('heartbeat')
const progress = row.reviewProgress!
const current = progress.phase ? phases.indexOf(progress.phase) : -1
@@ -14,7 +14,7 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
const state = progress.complete ? (row.supervisionStatus === 'no_change' && index > 0 ? 'skipped' : 'completed')
: index === 0 && current !== 0 ? 'completed'
: current > index ? 'completed'
: current === index ? (row.supervisionStatus ?? row.status)
: current === index ? (stopping === 'cancelled' ? 'cancelling' : stopping === 'paused' ? 'pausing' : row.supervisionStatus ?? row.status)
: current < 0 ? 'unknown' : 'pending'
return <li key={phase} data-phase={phase} data-state={state} aria-current={current === index && !progress.complete ? 'step' : undefined}>
<span className="supervisor-activity__step-number" aria-hidden="true">{index + 1}</span>
@@ -24,6 +24,6 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
</ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div>
}
+104 -2
View File
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { SupervisionActivity as Activity } from '../../shared/supervision-contracts'
import { SupervisorActivity } from './SupervisorActivity'
@@ -11,6 +11,10 @@ const row: Activity = {
startedAt: '2026-09-23T10:00:00Z', completedAt: null, timeRange: null, error: null,
summary: 'Saved heartbeat', resultId: null, heartbeatStatus: 'completed', supervisionStatus: 'running'
}
const render = (ui: React.ReactNode) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
afterEach(async () => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); await changeUiLocale('zh-CN') })
it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and recovery in %s', async locale => {
@@ -46,7 +50,7 @@ it('shows durable progress, pauses and continues the same run, and expands retai
fireEvent.click(await screen.findByRole('button', { name: '暂停回顾' }))
fireEvent.click(await screen.findByRole('button', { name: '继续已保存回顾' }))
expect(pause).toHaveBeenCalledWith({ runId: 'saved-run' })
expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' })
await waitFor(() => expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' }))
await waitFor(() => expect(activity).toHaveBeenCalledTimes(3))
fireEvent.click(await screen.findByRole('button', { name: '已保留事实与来源' }))
expect(await screen.findByText('Retained fact')).toBeInTheDocument()
@@ -54,6 +58,42 @@ it('shows durable progress, pauses and continues the same run, and expands retai
expect(screen.getAllByText(/message-id \[0, 18\)/).length).toBeGreaterThan(0)
})
it('allows cancelling a resumed review before the resume request settles', async () => {
vi.useFakeTimers()
const progress = { runId: 'saved-run', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([{ ...row, status: 'paused', supervisionStatus: 'paused', reviewProgress: progress }])
const execution = vi.fn().mockResolvedValue({ active: false })
let finishResume!: () => void, finishCancel!: () => void
const resume = vi.fn(() => {
activity.mockResolvedValue([{ ...row, reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId })
return new Promise<void>(resolve => { finishResume = resolve })
})
const cancel = vi.fn(() => {
activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
return new Promise<void>(resolve => { finishCancel = resolve })
})
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume, cancel } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' })))
await act(() => vi.advanceTimersByTimeAsync(10000))
expect(resume).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId })
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled()
expect(screen.getByRole('button', { name: '取消回顾' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '取消回顾' }))
expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId })
await act(async () => finishResume())
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled()
execution.mockResolvedValue({ active: false })
await act(async () => finishCancel())
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument()
})
it('shows no-change execution without a new result action', async () => {
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
...row, status: 'no_change', heartbeatStatus: 'no_change', supervisionStatus: 'no_change',
@@ -65,6 +105,68 @@ it('shows no-change execution without a new result action', async () => {
expect(screen.queryByRole('button', { name: '查看回顾' })).not.toBeInTheDocument()
})
it('cancels the automatic review ID and keeps cancelling visible until execution cleanup finishes', async () => {
vi.useFakeTimers()
const progress = { runId: 'review-id', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([{ ...row, reviewProgress: progress }])
const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId })
let finish!: () => void
const cancel = vi.fn(() => {
activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', error: 'Cancelled', reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
return new Promise<void>(resolve => { finish = resolve })
})
const pause = vi.fn()
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, cancel, pause } })
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} onPlanChange={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByLabelText('监督计划')).toBeEnabled()
expect(screen.queryByText('监督计划')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '取消回顾' }))
expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: 'review-id' })
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeDisabled()
expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled()
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(container.querySelector('[data-phase="extracting"]')).toHaveAttribute('data-state', 'cancelling')
expect(screen.queryByText('已取消')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument()
execution.mockResolvedValue({ active: false })
await act(async () => finish())
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getAllByText('已取消')).toHaveLength(2)
expect(screen.queryByRole('button', { name: /取消回顾|暂停回顾|继续已保存回顾/ })).not.toBeInTheDocument()
expect(screen.queryByText('本次运行失败,请查看错误详情后继续。已保存的批次仍保留。')).not.toBeInTheDocument()
expect(pause).not.toHaveBeenCalled()
})
it('rediscovers cancellation cleanup on mount and blocks resuming another saved run', async () => {
vi.useFakeTimers()
const progress = { runId: 'review-id', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([
{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress },
{ ...row, id: 'paused', status: 'paused', supervisionStatus: 'paused', reviewProgress: { ...progress, runId: 'paused-review' } }
])
const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
const resume = vi.fn()
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeDisabled()
execution.mockResolvedValue({ active: false })
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeEnabled()
execution.mockResolvedValue({ active: true, runId: 'new-automatic' })
await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' })))
expect(resume).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('新回顾正在整理')
})
it('polls sequentially only while active and ignores late responses after leaving', async () => {
vi.useFakeTimers()
let resolve!: (value: Activity[]) => void
+58 -17
View File
@@ -3,6 +3,7 @@ import { History, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { AssistantHeartbeatConfig, AssistantProject } from '../../shared/assistant-contracts'
import type { SupervisionActivity } from '../../shared/supervision-contracts'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, ScopeBadge } from './WorkspacePrimitives'
import { SupervisionBatchDetails } from './SupervisionBatchDetails'
import { SupervisionReviewStages } from './SupervisionReviewStages'
@@ -24,6 +25,9 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
const [error, setError] = useState<string>()
const [expanded, setExpanded] = useState<string>()
const [pending, setPending] = useState<string>()
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
const [stopping, setStopping] = useState<{ runId: string; kind: 'paused' | 'cancelled' }>()
const actionPending = useRef(false)
const filterRef = useRef<HTMLSelectElement>(null)
const [appliedConfigId, setAppliedConfigId] = useState(configId)
if (appliedConfigId !== configId) {
@@ -43,12 +47,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
setReading(true)
try {
if (!api?.activity) throw new Error(t('supervisor.unavailable'))
const result = await api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) })
const [result, current] = await Promise.all([
api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) }),
api.execution()
])
if (disposed) return
setRows(result)
setExecution(current)
setError(undefined)
// One request at a time, only while this page is active.
timer = setTimeout(() => void load(), result.some((row) => row.status === 'running') ? 2000 : 10000)
timer = setTimeout(() => void load(), current.active || result.some((row) => row.status === 'running') ? 2000 : 10000)
} catch (reason) {
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
} finally {
@@ -68,14 +76,46 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
setRefresh((value) => value + 1)
}
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage)
const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => {
if (actionPending.current || !row.reviewProgress) return
const runId = row.reviewProgress.runId
actionPending.current = true
setPending(row.id)
setError(undefined)
if (action !== 'resume') setStopping({ runId, kind: action === 'cancel' ? 'cancelled' : 'paused' })
try {
if (action === 'resume') {
const current = await api.execution()
setExecution(current)
if (current.active) {
setError(t('supervisor.runningHint'))
return
}
// Resume resolves only when execution stops; keep its pause/cancel controls available.
void api.resume({ runId }).then(
() => setRefresh(value => value + 1),
reason => setError(reason instanceof Error ? reason.message : String(reason))
)
return
}
await api[action]({ runId })
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
actionPending.current = false
setPending(undefined)
setStopping(undefined)
setRefresh(value => value + 1)
}
}
const planScope = configs.find(config => config.id === configId)?.scope
return <section className="supervisor-activity" aria-label={t('activity.title')} aria-busy={loading}>
<div className="supervisor-workspace__action-bar">
<ScopeBadge scope={planScope?.kind === 'global' ? { kind: 'global' }
: planScope?.kind === 'projects' ? { kind: 'projects', projectCount: planScope.projectIds.length }
: { kind: 'mixed' }} />
{onPlanChange && <label className="heartbeat-settings__field">{t('activity.planFilter')}
<select ref={filterRef} value={configId ?? ''} onChange={(event) => onPlanChange(event.target.value)}>
{onPlanChange && <label className="heartbeat-settings__field">
<select aria-label={t('activity.planFilter')} ref={filterRef} value={configId ?? ''} onChange={(event) => onPlanChange(event.target.value)}>
<option value="">{t('activity.allPlans')}</option>
{configId && !configs.some(config => config.id === configId) && <option value={configId}>{t('activity.unavailablePlan')}</option>}
{configs.map(config => <option key={config.id} value={config.id}>{config.name}</option>)}
@@ -94,10 +134,14 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</div>}
{loading ? <EmptyState icon={<RefreshCw size={24} />} variant="loading" title={t('activity.loading')} description={t('activity.loadingHint')} />
: !error && rows.length === 0 ? <EmptyState icon={<History size={24} />} title={t(configId ? 'activity.filteredEmpty' : 'activity.empty')} description={t(configId ? 'activity.filteredEmptyHint' : 'activity.emptyHint')} />
: <ol className="supervisor-activity__list">{rows.map((row) => <li key={`${row.kind}:${row.id}`}>
: <ol className="supervisor-activity__list">{rows.map((row) => {
const runId = row.reviewProgress?.runId
const stoppingKind = runId && stopping?.runId === runId ? stopping.kind
: runId && execution.active && execution.runId === runId ? execution.stopping : undefined
return <li key={`${row.kind}:${row.id}`}>
<article className="supervisor-activity__item">
<header><strong>{t(`activity.kind.${row.kind}`)}</strong>
<span className={`supervisor-activity__status supervisor-activity__status--${row.status}`}>{t(`activity.status.${row.status}`)}</span>
<span className={`supervisor-activity__status supervisor-activity__status--${stoppingKind ? 'running' : row.status}`} role={stoppingKind ? 'status' : undefined}>{t(stoppingKind === 'cancelled' ? 'reviewSettings.cancelling' : stoppingKind === 'paused' ? 'reviewSettings.pausing' : `activity.status.${row.status}`)}</span>
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
</header>
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
@@ -110,19 +154,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
{row.reviewProgress && <>
{row.reviewProgress.restartRequired && <p>{t('reviewSettings.restartRequired')}</p>}
<div className="supervisor-workspace__actions">
{row.supervisionStatus === 'running' && <button type="button" className="secondary-button" disabled={pending === row.id} onClick={() => {
setPending(row.id)
void api.pause({ runId: row.reviewProgress!.runId }).then(() => setRefresh(value => value + 1), reason => setError(String(reason))).finally(() => setPending(undefined))
}}>{t('reviewSettings.pause')}</button>}
{!row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={pending === row.id} onClick={() => {
setPending(row.id)
void api.resume({ runId: row.reviewProgress!.runId }).then(() => setRefresh(value => value + 1), reason => setError(String(reason))).finally(() => setPending(undefined))
}}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
{(row.supervisionStatus === 'running' || stoppingKind) && <>
<button type="button" className="secondary-button" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'pause')}>{t('reviewSettings.pause')}</button>
<button type="button" className="danger-ghost" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'cancel')}>{t('reviewSettings.cancel')}</button>
</>}
{!stoppingKind && !row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={!!pending || execution.active} onClick={() => void control(row, 'resume')}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
</div>
</>}
{row.reviewProgress && <SupervisionReviewStages row={row} />}
{row.error && <div className="supervisor-activity__failure">
{row.reviewProgress && <SupervisionReviewStages row={row} stopping={stoppingKind || undefined} />}
{row.error && row.supervisionStatus !== 'cancelled' && !stoppingKind && <div className="supervisor-activity__failure">
<p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error)
? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p>
<details className="supervisor-activity__diagnostics"><summary>{t('reviewSettings.diagnostics')}</summary>
@@ -149,7 +190,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</details>}
</details>
</article>
</li>)}</ol>}
</li>})}</ol>}
<nav className="supervisor-workspace__actions" aria-label={t('activity.pagination')}>
<button className="secondary-button" type="button" disabled={loading || reading || !active || offset === 0} onClick={() => reload(Math.max(0, offset - 50))}>{t('activity.previous')}</button>
<span>{t('activity.page', { page: offset / 50 + 1 })}</span>
+57 -3
View File
@@ -1,9 +1,13 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { SupervisorWorkspace } from './SupervisorWorkspace'
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) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
describe('SupervisorWorkspace', () => {
it('keeps dated historical content and exact graph selection when configuring and running a new review', async () => {
@@ -34,15 +38,65 @@ describe('SupervisorWorkspace', () => {
expect(onTabChange).toHaveBeenCalledWith('graph')
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(recap.textContent).toBe(frozenText)
expect(screen.getByText('Important final conclusion.')).toBeVisible()
expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理')
expect(screen.getByRole('button', { name: '回顾整理中…' })).toBeDisabled()
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
expect(screen.getByLabelText('关注范围')).toBeEnabled()
expect(screen.getByLabelText('时间范围')).toBeEnabled()
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
expect(screen.queryByText('关注范围')).not.toBeInTheDocument()
expect(screen.queryByText('时间范围')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
expect(screen.getByRole('status')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '活动记录' }))
expect(onOpenActivity).toHaveBeenCalledOnce()
expect(run).toHaveBeenCalledOnce()
expect(run).toHaveBeenCalledWith(expect.objectContaining({ scope: { kind: 'projects', projectIds: [project.id] } }))
await act(async () => finish())
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('discovers automatic execution on mount, preserves dismissal through polling, and guards start until cleanup', async () => {
vi.useFakeTimers()
const execution = vi.fn().mockResolvedValue({ active: true, runId: 'automatic', stopping: 'cancelled' })
const run = vi.fn().mockResolvedValue(undefined)
const cancel = vi.fn()
window.goodbuddy = { supervision: { execution, overview: async () => [], run, cancel } } as never
const view = render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(cancel).not.toHaveBeenCalled()
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(run).not.toHaveBeenCalled()
execution.mockResolvedValue({ active: false })
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
expect(run).toHaveBeenCalledOnce()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
view.unmount()
const calls = execution.mock.calls.length
await act(() => vi.advanceTimersByTimeAsync(10000))
expect(execution).toHaveBeenCalledTimes(calls)
vi.useRealTimers()
})
it('turns a backend busy race into a dismissible ongoing notice', async () => {
const run = vi.fn().mockRejectedValue(new Error('SUPERVISION_REVIEW_BUSY'))
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
})
it('graph navigation discards a late overview before it can request the old graph', async () => {
let resolveOverview!: (value: unknown) => void
@@ -138,7 +192,7 @@ describe('SupervisorWorkspace', () => {
expect(tokens, token).toContain(`${token}:`)
}
})
afterEach(() => { cleanup(); window.goodbuddy = {} as never })
afterEach(() => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never })
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
window.goodbuddy = {} as never
+54 -18
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen, Network, RefreshCw } from 'lucide-react'
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState } from './WorkspacePrimitives'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
@@ -70,6 +71,10 @@ export function SupervisorWorkspace({
const [days, setDays] = useState(7)
const [lastRequest, setLastRequest] = useState<SupervisionRunRequest>()
const [pausedReview, setPausedReview] = useState(false)
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
const [running, setRunning] = useState(false)
const runPending = useRef(false)
const [dismissedNotice, setDismissedNotice] = useState(false)
const [resultId, setResultId] = useState<string | undefined>(graphNavigation?.resultId)
const [appliedNavigation, setAppliedNavigation] = useState(graphNavigation)
if (appliedNavigation !== graphNavigation) {
@@ -174,8 +179,34 @@ export function SupervisorWorkspace({
return () => { window.clearTimeout(task); invalidate() }
}, [refresh, graphNavigation])
useEffect(() => {
if (!api) return
let disposed = false
let wasActive = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const next = await api.execution()
if (!disposed) {
setExecution(next)
if (next.active && !wasActive && !runPending.current) setDismissedNotice(false)
wasActive = next.active
}
} catch (reason) {
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
} finally {
if (!disposed) timer = setTimeout(() => void poll(), 2000)
}
}
void poll()
return () => { disposed = true; clearTimeout(timer) }
}, [api, t])
const run = async () => {
if (!api || pending || loading) return
if (!api) return
setDismissedNotice(false)
if (runPending.current) return
runPending.current = true
const generation = loadGeneration.current
const to = new Date()
const request: SupervisionRunRequest = {
@@ -189,23 +220,31 @@ export function SupervisorWorkspace({
to: to.toISOString()
}
}
setLastRequest(request)
setPausedReview(false)
setPending('run')
setErrorAction('run')
setError(undefined)
try {
const current = await api.execution()
setExecution(current)
if (current.active) return
setLastRequest(request)
setPausedReview(false)
setRunning(true)
const outcome = await api.run(request) as { status?: string } | undefined
if (generation !== loadGeneration.current) return
setExecution(await api.execution())
setPausedReview(outcome?.status === 'paused')
if (generation !== loadGeneration.current) return
selectedResult.current = undefined
setPending(undefined)
await refresh()
} catch (reason) {
if (/SUPERVISION_REVIEW_BUSY/.test(String(reason))) {
setExecution({ active: true })
return
}
if (generation !== loadGeneration.current) return
setError(errorText(reason))
} finally {
if (generation === loadGeneration.current) setPending(undefined)
runPending.current = false
setRunning(false)
}
}
@@ -415,6 +454,7 @@ export function SupervisorWorkspace({
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const busy = !!api && (loading || pending !== undefined)
const showRunNotice = (execution.active || running || pausedReview) && !dismissedNotice
return (
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
@@ -449,9 +489,10 @@ export function SupervisorWorkspace({
</button>
</div>
)}
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__run-status" role="status">
<span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
{showRunNotice && <div className="supervisor-workspace__run-status" role="status">
<span>{t(execution.stopping === 'cancelled' ? 'reviewSettings.cancelling' : execution.stopping === 'paused' ? 'reviewSettings.pausing' : pausedReview && !running && !execution.active ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
<button type="button" className="icon-button" aria-label={t('supervisor.dismiss')} title={t('supervisor.dismiss')} onClick={() => setDismissedNotice(true)}><X size={16} aria-hidden="true" /></button>
</div>}
{error && (
<div className="supervisor-workspace__inline-error" role="alert">
@@ -481,10 +522,9 @@ export function SupervisorWorkspace({
<>
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<label>
{t('supervisor.scope')}
<select
aria-label={t('supervisor.scope')}
value={projectId}
disabled={busy}
onChange={(event) => setProjectId(event.target.value)}
>
<option value="global">{t('center.scope.global')}</option>
@@ -498,10 +538,9 @@ export function SupervisorWorkspace({
</select>
</label>
<label>
{t('supervisor.period')}
<select
aria-label={t('supervisor.period')}
value={days}
disabled={busy}
onChange={(event) => setDays(Number(event.target.value))}
>
{[1, 7, 30].map((value) => (
@@ -513,12 +552,9 @@ export function SupervisorWorkspace({
</label>
<button
className="primary-button"
disabled={busy}
onClick={() => void run()}
>
{pending === 'run'
? t('supervisor.running')
: t('supervisor.run')}
{t('supervisor.run')}
</button>
<button
className="secondary-button"
@@ -12,6 +12,7 @@ export const heartbeat = {
pauseHelp: 'Reviews run continuously until complete and save each batch. Pause and continue from Activity records.',
invalid: 'Enter whole numbers within the displayed ranges.',
pause: 'Pause review', resume: 'Continue saved review', facts: 'Retained facts and sources',
cancel: 'Cancel review', cancelling: 'Cancelling review…', pausing: 'Pausing review…',
pausedHint: 'Review is incomplete. Successful batches are saved. Open Activity to inspect progress and continue.',
restartRequired: 'A frozen source changed or was removed. Saved facts remain available. Start a new review from Work review; the next automatic check will also use a new run.',
factsHelp: 'Use summaries to navigate, then expand saved batches for detailed records and source text. Check the model\'s findings against the sources.',
@@ -39,7 +40,7 @@ export const heartbeat = {
activity: {
stages: 'Review stages',
phases: { collecting: 'Collect sources', extracting: 'Review batches', summarizing: 'Merge summaries', saving: 'Publish review' },
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', cancelled: 'Cancelled', cancelling: 'Cancelling', pausing: 'Pausing', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
navigationSaved: '{{count}} navigation merges saved',
@@ -57,7 +58,7 @@ export const heartbeat = {
title: 'Activity records', description: 'Saved manual reviews and automatic supervision runs. Updates while this tab is open.',
loading: 'Loading activity', loadingHint: 'Reading saved execution records.', empty: 'No activity recorded', emptyHint: 'Run a manual review or create a plan in Automatic supervision.',
kind: { supervision: 'Work review', heartbeat: 'Automatic supervision' },
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue' },
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue', cancelled: 'Cancelled' },
triggers: { manual: 'User', scheduled: 'Schedule', heartbeat: 'Heartbeat' },
trigger: 'Triggered by', started: 'Started', finished: 'Finished', unknownScope: 'Execution scope not recorded',
heartbeatStage: 'Heartbeat report', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
@@ -9,6 +9,7 @@ export const heartbeat = {
pauseHelp: '回顾持续处理至完成,每批保存进度;可从活动记录主动暂停并继续。',
invalid: '请输入所示范围内的整数。',
pause: '暂停回顾', resume: '继续已保存回顾', facts: '已保留事实与来源',
cancel: '取消回顾', cancelling: '正在取消回顾…', pausing: '正在暂停回顾…',
pausedHint: '本次回顾尚未完成,成功批次已保存。请到活动页查看进度并继续。',
restartRequired: '冻结来源已修改或移除,已保存事实仍可读取。请从工作回顾新建回顾;下次自动检查也会使用新运行。',
factsHelp: '摘要用于导航,可展开已保存批次查看详细记录与原文。请对照来源核对模型整理的内容。',
@@ -36,7 +37,7 @@ export const heartbeat = {
activity: {
stages: '回顾阶段',
phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' },
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', cancelled: '已取消', cancelling: '正在取消', pausing: '正在暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
navigationSaved: '已保存 {{count}} 次导航合并',
@@ -54,7 +55,7 @@ export const heartbeat = {
title: '活动记录', description: '记录手动回顾和自动监督的实际执行,停留在此页签时自动刷新。',
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在自动监督中创建计划。',
kind: { supervision: '工作回顾', heartbeat: '自动监督' },
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续' },
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续', cancelled: '已取消' },
triggers: { manual: '用户触发', scheduled: '定时计划', heartbeat: '心跳触发' },
trigger: '触发方式', started: '开始时间', finished: '结束时间', unknownScope: '未保存执行范围',
heartbeatStage: '心跳报告', supervisionStage: '监督回顾', notRecorded: '无执行记录',
+8 -3
View File
@@ -15,10 +15,9 @@
align-self: stretch;
}
.supervisor-workspace__run-status {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-default);
@@ -26,6 +25,12 @@
background: var(--accent-subtle);
color: var(--text-secondary);
}
.supervisor-workspace__run-status > span {
overflow-wrap: anywhere;
}
.supervisor-workspace__run-status > .icon-button {
grid-column: 3;
}
.supervisor-workspace p,
.supervisor-workspace time {
overflow-wrap: anywhere;