fix: complete supervisor review coverage and execution workflow
Replace truncated review input with paged, resumable batches and retain extracted facts and source references through navigation summaries. Expose concurrency, timeouts, batch budgets and response capacity in supervisor settings. Separate automatic supervision from review results, activity and settings; use a standard plan modal and show execution stages with saved batch details. Fix historical entity identity mapping, navigation output validation and arbitrary summary length failures. Include focused regressions, isolated real-data validation scripts and updated design and verification records. Overall layout acceptance remains outstanding; this commit does not claim that all visual issues are resolved. Release note: 监督回顾支持完整分页、分批保存和暂停后继续,可配置并发、超时及响应容量;修复实体引用、汇总校验和长摘要报错,并重新组织自动监督与活动记录入口。
This commit is contained in:
@@ -691,6 +691,9 @@ const api: DesktopApi = {
|
||||
history: vi.fn(async () => ({ runs: [], entries: [] })),
|
||||
},
|
||||
supervision: {
|
||||
pause: vi.fn(async () => undefined),
|
||||
resume: vi.fn(async () => undefined),
|
||||
batches: vi.fn(async () => []),
|
||||
activity: vi.fn(async () => []),
|
||||
overview: vi.fn(async () => []),
|
||||
run: vi.fn(async () => undefined),
|
||||
@@ -13079,7 +13082,7 @@ describe("App", () => {
|
||||
name: "旧项目心跳",
|
||||
timezone: "Asia/Shanghai",
|
||||
recurrence: { type: "daily", localTime: "09:00" },
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
lookbackHours: 24,
|
||||
retentionDays: 30,
|
||||
nextRunAt: "2026-08-05T01:00:00.000Z",
|
||||
@@ -13090,12 +13093,23 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "监督者" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "设置" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "自动监督" }));
|
||||
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
|
||||
selectProjectOption(secondProject.name);
|
||||
fireEvent.click(screen.getByRole("button", { name: "监督者" }));
|
||||
|
||||
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑 旧项目心跳' }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.closest('.app-shell')).toBeNull();
|
||||
expect(document.querySelector('.app-shell')).toHaveProperty('inert', true);
|
||||
fireEvent.change(within(dialog).getByLabelText('计划名称'), { target: { value: 'Updated paused plan' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存自动监督计划' }));
|
||||
await waitFor(() => expect(api.heartbeats.update).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000701', expect.objectContaining({
|
||||
name: 'Updated paused plan', enabled: false, scope: { kind: 'projects', projectIds: [projectId] }
|
||||
})));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(document.querySelector('.app-shell')).toHaveProperty('inert', false);
|
||||
});
|
||||
|
||||
it("automatically snapshots the conversation project on new activity", async () => {
|
||||
@@ -13932,6 +13946,37 @@ describe("App", () => {
|
||||
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('saves Supervisor timeouts and concurrency through application settings and restores them on reopen', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '监督者' }))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '设置' }))
|
||||
const report = await screen.findByLabelText('心跳报告超时(秒)')
|
||||
await waitFor(() => expect(report).toBeEnabled())
|
||||
expect(report).toHaveValue(240)
|
||||
fireEvent.change(report, { target: { value: '600' } })
|
||||
fireEvent.change(screen.getByLabelText('监督者整理超时(秒)'), { target: { value: '30' } })
|
||||
expect(screen.getByLabelText('监督模型并发数')).toHaveValue(1)
|
||||
fireEvent.change(screen.getByLabelText('监督模型并发数'), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存模型设置' }))
|
||||
await waitFor(() => expect(api.updates!.updateSettings).toHaveBeenLastCalledWith({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 2 }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存模型设置' })).toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(screen.getByLabelText('心跳报告超时(秒)')).toHaveValue(600)
|
||||
expect(screen.getByLabelText('监督者整理超时(秒)')).toHaveValue(30)
|
||||
expect(screen.getByLabelText('监督模型并发数')).toHaveValue(2)
|
||||
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } })
|
||||
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
await waitFor(() => expect(api.updates!.updateSettings).toHaveBeenLastCalledWith({
|
||||
supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300 }
|
||||
}))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
|
||||
})
|
||||
|
||||
it('hides the Supervisor sidebar entry without deleting automatic wake-up plans', async () => {
|
||||
const plan = {
|
||||
id: 'supervisor-plan-1',
|
||||
|
||||
@@ -11433,6 +11433,12 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
{isApplicationEnabled(applicationSettings, 'heartbeat') ? <HeartbeatCenter
|
||||
applicationSettings={applicationSettings}
|
||||
applicationSettingsPending={applicationSettingsPending}
|
||||
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
||||
applicationSettingsError={applicationSettingsError}
|
||||
onUpdateApplicationSettings={updateApplicationSettings}
|
||||
onRetryApplicationSettings={() => void reloadApplicationSettings()}
|
||||
active={view === 'heartbeat'}
|
||||
graphNavigation={supervisionGraphNavigation}
|
||||
configs={assistantHeartbeats}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
|
||||
import { supervisionRunRequestSchema } from '../../shared/supervision-contracts'
|
||||
import { PageShell } from './WorkspacePrimitives'
|
||||
import i18n from './i18n'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -174,12 +175,9 @@ describe('HeartbeatCenter', () => {
|
||||
graph: vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
} })
|
||||
})
|
||||
function render(ui: React.ReactElement, overview = true) {
|
||||
function render(ui: React.ReactElement, overview = false) {
|
||||
const result = renderComponent(ui)
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('supervisor.settings', { ns: 'heartbeat' }) }))
|
||||
if (overview && screen.queryByRole('tab', { name: i18n.t('center.tabs.overview', { ns: 'heartbeat' }) })) {
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('center.tabs.overview', { ns: 'heartbeat' }) }))
|
||||
}
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t(overview ? 'activity.title' : 'supervisor.automatic', { ns: 'heartbeat' }) }))
|
||||
return result
|
||||
}
|
||||
afterEach(async () => {
|
||||
@@ -205,12 +203,20 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.getByRole('button', { name: t('supervisor.run') })).toBeEnabled()
|
||||
expect(screen.getByRole('combobox', { name: t('supervisor.scope') })).toBeVisible()
|
||||
expect(screen.getByText(t('supervisor.sourcesHint'))).toBeVisible()
|
||||
for (const key of ['supervisor.recap', 'supervisor.graph', 'activity.title', 'supervisor.settings']) {
|
||||
for (const key of ['supervisor.recap', 'supervisor.graph', 'supervisor.automatic', 'activity.title', 'supervisor.settings']) {
|
||||
fireEvent.click(screen.getByRole('tab', { name: t(key) }))
|
||||
const panel = screen.getByRole('tabpanel', { name: t(key) })
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
|
||||
expect(within(panel).queryByRole('heading', { name: t(key) })).not.toBeInTheDocument()
|
||||
expect(within(panel).getByRole('button', { name: key === 'supervisor.settings' ? t('center.actions.refreshAriaLabel') : t('center.actions.refresh') })).toBeVisible()
|
||||
expect(screen.getAllByRole('tablist')).toHaveLength(1)
|
||||
expect(within(panel).queryByRole('tablist')).not.toBeInTheDocument()
|
||||
if (key === 'supervisor.settings') {
|
||||
expect(within(panel).queryByRole('button', { name: t('center.actions.refreshAriaLabel') })).not.toBeInTheDocument()
|
||||
expect(panel.querySelector('.scope-badge')).not.toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('heading', { name: t('center.history.timelineTitle') })).not.toBeInTheDocument()
|
||||
} else if (key !== 'supervisor.automatic') {
|
||||
expect(within(panel).getByRole('button', { name: t('center.actions.refresh') })).toBeVisible()
|
||||
}
|
||||
if (key === 'supervisor.graph') {
|
||||
expect(within(panel).getByRole('region', { name: t('supervisor.canvas') })).toBeVisible()
|
||||
expect(within(panel).getByRole('heading', { name: t('supervisor.inspector') })).toBeVisible()
|
||||
@@ -222,12 +228,12 @@ describe('HeartbeatCenter', () => {
|
||||
expect(panel.querySelector('.scope-badge')).toBeVisible()
|
||||
}
|
||||
}
|
||||
expect(screen.getByRole('heading', { name: t('settings.title') })).toBeVisible()
|
||||
expect(screen.queryByLabelText(t('settings.nameLabel'))).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: t('supervisor.automatic') }))
|
||||
expect(screen.getByText(t('settings.scheduleHelp'))).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: t('settings.enableAriaLabel') })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: t('settings.createTitle') })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: i18n.t('settings.runNowAriaLabel', { ns: 'heartbeat', name: config.name }) })).toBeVisible()
|
||||
expect(document.querySelector('.supervisor-workspace__action-bar .scope-badge')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('center.actions.refreshAriaLabel') }))
|
||||
fireEvent.click(screen.getByRole('button', { name: t('settings.refreshPlans') }))
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
@@ -238,27 +244,67 @@ describe('HeartbeatCenter', () => {
|
||||
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 } })
|
||||
renderComponent(<HeartbeatCenter {...createProps()} />)
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '活动', '设置'])
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动' }))
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看回顾' }))
|
||||
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' })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动' }))
|
||||
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))
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('edits separate supervision timeouts and preserves failed edits (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
const onUpdateApplicationSettings = vi.fn(async () => false)
|
||||
const applicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
const props = createProps({ applicationSettings, onUpdateApplicationSettings })
|
||||
const view = render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('supervisor.settings', { ns: 'heartbeat' }) }))
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
const report = screen.getByLabelText(t('timeouts.report'))
|
||||
const organize = screen.getByLabelText(t('timeouts.organize'))
|
||||
const concurrency = screen.getByLabelText(t('timeouts.concurrency'))
|
||||
expect(concurrency).toHaveValue(1)
|
||||
expect(screen.getByText(t('timeouts.concurrencyHelp'))).toBeVisible()
|
||||
expect(report).toHaveValue(240)
|
||||
expect(organize).toHaveValue(240)
|
||||
expect(screen.getByText(t('timeouts.transport'))).toBeVisible()
|
||||
for (const value of ['', '29', '601', '30.5']) {
|
||||
fireEvent.change(report, { target: { value } })
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
}
|
||||
fireEvent.change(report, { target: { value: '600' } })
|
||||
fireEvent.change(organize, { target: { value: '30' } })
|
||||
for (const value of ['', '0', '5', '1.5']) {
|
||||
fireEvent.change(concurrency, { target: { value } })
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
}
|
||||
fireEvent.change(concurrency, { target: { value: '4' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }))
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettingsError="Save failed" />)
|
||||
expect(report).toHaveValue(600)
|
||||
expect(organize).toHaveValue(30)
|
||||
expect(concurrency).toHaveValue(4)
|
||||
expect(screen.getByText('Save failed')).toBeVisible()
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
expect(screen.getByRole('tab', { name: t('center.tabs.plans') })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: t('supervisor.automatic') })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByText(t('settings.empty'))).toBeVisible()
|
||||
expect(screen.getByText(t('settings.scheduleHelp'))).toBeVisible()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('settings.createTitle') }))
|
||||
expect(screen.getByLabelText(t('settings.recurrenceAriaLabel'))).toHaveValue('daily')
|
||||
expect(screen.getByLabelText(t('settings.timeAriaLabel'))).toHaveValue('09:00')
|
||||
expect(screen.getByLabelText(t('settings.lookbackAriaLabel'))).toHaveValue(48)
|
||||
@@ -274,6 +320,7 @@ describe('HeartbeatCenter', () => {
|
||||
it.each(['daily', 'weekly'])('saves the explicitly configured %s plan and rejects invalid windows', async (frequency) => {
|
||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('settings.createTitle', { ns: 'heartbeat' }) }))
|
||||
fireEvent.change(screen.getByLabelText('监督频率'), { target: { value: frequency } })
|
||||
if (frequency === 'weekly') fireEvent.change(screen.getByLabelText('监督星期'), { target: { value: '5' } })
|
||||
fireEvent.change(screen.getByLabelText('监督时间'), { target: { value: '16:35' } })
|
||||
@@ -321,7 +368,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(props.onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps pause, resume, run and confirmed deletion available directly in settings', async () => {
|
||||
it('keeps pause, resume, run and confirmed deletion available directly in automatic supervision', async () => {
|
||||
const props = createProps()
|
||||
const view = render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: `暂停 ${config.name}` }))
|
||||
@@ -347,26 +394,14 @@ describe('HeartbeatCenter', () => {
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('SMART HEARTBEAT')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Run now' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Run now' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
expect(screen.getByText('Project: Default project')).toHaveClass(
|
||||
'scope-badge'
|
||||
)
|
||||
expect(
|
||||
screen.getByText(/Every day at 09:00 · Default project/u)
|
||||
).toBeInTheDocument()
|
||||
const englishDate = new Intl.DateTimeFormat('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(config.nextRunAt))
|
||||
expect(screen.getAllByText(englishDate).length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: /Pending suggestions/ })
|
||||
screen.getByRole('tab', { name: i18n.t('supervisor.automatic', { ns: 'heartbeat' }) })
|
||||
)
|
||||
expect(screen.getByText(task.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(task.instructions)).toBeInTheDocument()
|
||||
@@ -377,6 +412,7 @@ describe('HeartbeatCenter', () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: 'Automatic supervision' })
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('settings.createTitle', { ns: 'heartbeat' }) }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Selected projects' })
|
||||
)
|
||||
@@ -392,12 +428,9 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass(
|
||||
'scope-badge'
|
||||
)
|
||||
expect(screen.getByText(/每天 09:00 · 默认项目/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('1 个计划运行中')).toBeInTheDocument()
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('本次心跳发现用户偏好简洁回复,并建议整理交付计划。')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
|
||||
expect(screen.getByRole('region', { name: '当前状态' })).toBeInTheDocument()
|
||||
})
|
||||
@@ -428,7 +461,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.getByLabelText('2 个项目')).toHaveTextContent(
|
||||
'2 个项目'
|
||||
)
|
||||
expect(screen.queryByText(/全局/u)).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: '运行概览' })).queryByText(/全局/u)).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<HeartbeatCenter
|
||||
@@ -445,7 +478,7 @@ describe('HeartbeatCenter', () => {
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByLabelText('项目 + 全局')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: '运行概览' })).getByLabelText('项目 + 全局')).toBeInTheDocument()
|
||||
|
||||
await i18n.changeLanguage('en-US')
|
||||
rerender(
|
||||
@@ -468,14 +501,11 @@ describe('HeartbeatCenter', () => {
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '报告趋势' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '最近回顾' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: '最近回顾' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('CURRENT PULSE')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GROWTH TREND')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('LATEST REPORT')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /待处理建议/u }))
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '待确认记忆' })
|
||||
).toBeInTheDocument()
|
||||
@@ -485,26 +515,21 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.queryByText('MEMORY GROWTH')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('NEXT ACTIONS')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '报告与记录' }))
|
||||
expect(screen.queryByRole('heading', { level: 2, name: '最近回顾' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '回顾报告' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '运行记录' })
|
||||
screen.getByRole('heading', { level: 2, name: '自动监督报告' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '运行记录' })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByText('HEARTBEAT TIMELINE')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('RUN AUDIT')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '自动监督' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '自动监督' }))
|
||||
expect(
|
||||
screen.getByText('按计划只读回顾所选范围,不调用工具;建议由你确认和处理。')
|
||||
screen.getByRole('heading', { level: 2, name: '回顾算法' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建计划' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('turns heartbeat findings into explicit user actions', async () => {
|
||||
@@ -522,7 +547,7 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: /待处理建议/ })
|
||||
screen.getByRole('tab', { name: '自动监督' })
|
||||
)
|
||||
expect(screen.getByText(memory.content)).toBeInTheDocument()
|
||||
expect(screen.getByText(task.title)).toBeInTheDocument()
|
||||
@@ -562,24 +587,96 @@ describe('HeartbeatCenter', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '立即回顾' })
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: `立即运行 ${config.name}` }))
|
||||
await waitFor(() =>
|
||||
expect(onRunNow).toHaveBeenCalledWith(config.id)
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '刷新监督者' })
|
||||
screen.getByRole('button', { name: '刷新自动监督' })
|
||||
)
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce())
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '报告与记录' }))
|
||||
expect(screen.getByText('模型暂时不可用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
expect(screen.queryByText('模型暂时不可用')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '展开完整报告' })
|
||||
)
|
||||
expect(screen.getByText(entry.highlights[0]!)).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText(entry.highlights[0]!)).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps long automatic suggestions expandable after leaving and returning to the tab', () => {
|
||||
const longMemory = { ...memory, content: memory.content.repeat(20) }
|
||||
const longTask = { ...task, instructions: task.instructions.repeat(20) }
|
||||
render(<HeartbeatCenter {...createProps({ memories: [longMemory], tasks: [longTask] })} />)
|
||||
const memorySection = screen.getByRole('region', { name: '待确认记忆' })
|
||||
const expand = within(memorySection).getByRole('button', { name: i18n.t('center.suggestions.expandContent', { ns: 'heartbeat' }) })
|
||||
fireEvent.click(expand)
|
||||
expect(expand).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(within(memorySection).getByText(longMemory.content)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
expect(screen.queryByText(longMemory.content)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(screen.getByText(longMemory.content)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
const taskSection = screen.getByRole('region', { name: '行动建议' })
|
||||
fireEvent.click(within(taskSection).getByRole('button', { name: i18n.t('center.suggestions.expandContent', { ns: 'heartbeat' }) }))
|
||||
expect(within(taskSection).getByText(longTask.instructions)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
})
|
||||
|
||||
it('keeps report pagination, suggestion actions and run pagination in automatic supervision', async () => {
|
||||
const props = createProps({
|
||||
entries: Array.from({ length: 21 }, (_, index) => ({ ...entry, id: `entry-${index}`, summary: `Report ${index}` })),
|
||||
runs: Array.from({ length: 21 }, (_, index) => ({ ...runs[1]!, id: `run-${index}`, error: `Run error ${index}` }))
|
||||
})
|
||||
renderComponent(<HeartbeatCenter {...props} />)
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
const reports = screen.getByRole('region', { name: '自动监督报告' })
|
||||
expect(within(reports).queryByText('Report 20')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(reports).getByRole('button', { name: i18n.t('center.history.loadMoreReports', { ns: 'heartbeat' }) }))
|
||||
expect(within(reports).getByText('Report 20')).toBeVisible()
|
||||
fireEvent.click(within(reports).getAllByRole('button', { name: '展开完整报告' })[20]!)
|
||||
expect(within(reports).getByText(entry.highlights[0]!)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '忽略' }))
|
||||
await waitFor(() => expect(props.onSetMemoryStatus).toHaveBeenCalledWith(memory.id, 'rejected'))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('center.suggestions.ignoreSuggestion', { ns: 'heartbeat' }) }))
|
||||
await waitFor(() => expect(props.onSetTaskStatus).toHaveBeenCalledWith(task.id, 'cancelled'))
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '工作回顾' }), { key: 'End' })
|
||||
expect(screen.getByRole('tab', { name: '设置' })).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: '运行记录' })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '设置' }), { key: 'ArrowLeft' })
|
||||
expect(screen.getByRole('tab', { name: '活动记录' })).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: '运行记录' })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '活动记录' }), { key: 'ArrowLeft' })
|
||||
expect(screen.getByRole('tab', { name: '自动监督' })).toHaveFocus()
|
||||
const audit = screen.getByRole('region', { name: '运行记录' })
|
||||
expect(within(audit).queryByText('Run error 20')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(audit).getByRole('button', { name: i18n.t('center.history.loadMoreRuns', { ns: 'heartbeat' }) }))
|
||||
expect(within(audit).getByText('Run error 20')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText('Report 20')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '收起报告' })).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps successful supervision results independent of automatic reports (empty=%s)', async (empty) => {
|
||||
window.goodbuddy.supervision.overview = vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Successful work review', changeDigest: '', createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]) as never
|
||||
renderComponent(<HeartbeatCenter {...createProps(empty ? { configs: [], entries: [], runs: [], memories: [], tasks: [] } : {})} />)
|
||||
expect(await screen.findByText('Successful work review')).toBeVisible()
|
||||
const recap = screen.getByRole('tabpanel', { name: '工作回顾' })
|
||||
expect(recap.querySelector('#heartbeat-panel-history, #heartbeat-panel-suggestions, .scope-badge')).toBeNull()
|
||||
expect(within(recap).getAllByRole('button', { name: '刷新' })).toHaveLength(1)
|
||||
expect(within(recap).queryByText(/暂无自动监督报告|还没有成功回顾/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
const reports = screen.getByRole('region', { name: '自动监督报告' })
|
||||
expect(within(reports).queryByText(entry.summary) !== null).toBe(!empty)
|
||||
expect(within(reports).queryByText(/暂无自动监督报告/) !== null).toBe(empty)
|
||||
expect(screen.queryByText('Successful work review')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains the irreversible impact before deleting a plan', () => {
|
||||
@@ -603,6 +700,7 @@ describe('HeartbeatCenter', () => {
|
||||
it('keeps scope choices visible while showing help for the selected scope', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('settings.createTitle', { ns: 'heartbeat' }) }))
|
||||
const help = screen.getByRole('button', { name: '项目范围' })
|
||||
expect(help.closest('label, [role="tab"]')).toBeNull()
|
||||
fireEvent.click(help)
|
||||
@@ -619,7 +717,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.queryByRole('region', { name: '当前状态' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { name: '自动监督' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '回顾算法' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes manual project and time selections without requiring an automatic plan', async () => {
|
||||
@@ -660,7 +758,7 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '配置自动监督' })
|
||||
screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '指定项目' })
|
||||
@@ -728,7 +826,7 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '配置自动监督' })
|
||||
screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '自动监督' })
|
||||
@@ -756,7 +854,7 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('正在加载监督者')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveAttribute(
|
||||
expect(within(screen.getByRole('region', { name: '运行概览' })).getByRole('status')).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'true'
|
||||
)
|
||||
@@ -789,8 +887,187 @@ describe('HeartbeatCenter', () => {
|
||||
|
||||
expect(screen.getByText('监督者刷新失败')).toBeInTheDocument()
|
||||
expect(screen.getByText(config.name)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(entry.summary)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('uses the shared modal header and footer for create and edit (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
render(<HeartbeatCenter {...createProps()} />, false)
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
for (const editing of [false, true]) {
|
||||
const opener = screen.getByRole('button', { name: editing
|
||||
? i18n.t('settings.editAriaLabel', { ns: 'heartbeat', name: config.name })
|
||||
: t('settings.createTitle') })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
const dialog = screen.getByRole('dialog', { name: t(editing ? 'settings.editTitle' : 'settings.createTitle') })
|
||||
const header = dialog.querySelector(':scope > header')!
|
||||
const footer = dialog.querySelector(':scope > footer')!
|
||||
expect(header).toHaveClass('custom-task-dialog__header')
|
||||
expect(footer).toHaveClass('custom-task-dialog__actions')
|
||||
expect(dialog.querySelector(':scope > .custom-task-dialog__content')).not.toBeNull()
|
||||
const close = within(header as HTMLElement).getByRole('button', { name: t('settings.close') })
|
||||
expect(close).toHaveClass('icon-button')
|
||||
expect(close).toHaveAttribute('title', t('settings.close'))
|
||||
expect(header.querySelectorAll('button')).toHaveLength(1)
|
||||
const cancel = within(footer as HTMLElement).getByRole('button', { name: t('supervisor.cancel') })
|
||||
const save = within(footer as HTMLElement).getByRole('button', { name: t(editing ? 'settings.saveAriaLabel' : 'settings.enableAriaLabel') })
|
||||
expect(cancel).toHaveClass('secondary-button')
|
||||
expect(save).toHaveClass('primary-button')
|
||||
expect(cancel.nextElementSibling).toBe(save)
|
||||
close.focus()
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(save).toHaveFocus()
|
||||
fireEvent.keyDown(save, { key: 'Tab' })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.click(cancel)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['X', 'cancel', 'Escape', 'backdrop'])('guards unsaved changes through %s for create and edit', async (action) => {
|
||||
const props = createProps()
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
for (const editing of [false, true]) {
|
||||
const opener = screen.getByRole('button', { name: editing ? `编辑 ${config.name}` : '创建自动监督计划' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
const dismiss = () => {
|
||||
if (action === 'Escape') fireEvent.keyDown(screen.getByLabelText('计划名称'), { key: 'Escape' })
|
||||
else if (action === 'backdrop') fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
|
||||
else fireEvent.click(screen.getByRole('button', { name: action === 'X' ? '关闭自动监督计划' : '取消' }))
|
||||
}
|
||||
dismiss()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
fireEvent.click(opener)
|
||||
fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: 'Unsaved draft' } })
|
||||
dismiss()
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('放弃未保存的计划修改?')
|
||||
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭自动监督计划' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('计划名称')).toHaveFocus())
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Unsaved draft')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃修改' }))
|
||||
expect(opener).toHaveFocus()
|
||||
}
|
||||
expect(props.onCreate).not.toHaveBeenCalled()
|
||||
expect(props.onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks every close action and duplicate submission while saving, then keeps the failed draft', async () => {
|
||||
let rejectSave!: (reason: Error) => void
|
||||
const onCreate = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectSave = reject }))
|
||||
render(<HeartbeatCenter {...createProps({ onCreate })} />, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建自动监督计划' }))
|
||||
fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: 'Pending draft' } })
|
||||
const save = screen.getByRole('button', { name: '保存并启用计划' })
|
||||
fireEvent.click(save)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-busy', 'true')
|
||||
for (const button of [save, screen.getByRole('button', { name: '关闭自动监督计划' }), screen.getByRole('button', { name: '取消' })]) {
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.click(button)
|
||||
}
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
fireEvent.mouseDown(dialog.parentElement!)
|
||||
expect(dialog).toHaveAccessibleName('创建自动监督计划')
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
rejectSave(new Error('Save unavailable'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Save unavailable')
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Pending draft')
|
||||
expect(save).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭自动监督计划' }))
|
||||
expect(dialog).toHaveAccessibleName('放弃未保存的计划修改?')
|
||||
})
|
||||
|
||||
it('keeps a failed create draft, guards Escape/backdrop/cancel, traps focus and restores the opener', async () => {
|
||||
const onCreate = vi.fn(async () => {}).mockRejectedValueOnce(new Error('Cannot save plan'))
|
||||
render(<HeartbeatCenter {...createProps({ onCreate })} />, false)
|
||||
const opener = screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
const name = screen.getByLabelText('计划名称')
|
||||
expect(name).toHaveFocus()
|
||||
expect(document.querySelector('form form')).toBeNull()
|
||||
fireEvent.change(name, { target: { value: 'Draft plan' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存并启用计划' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Cannot save plan')
|
||||
expect(name).toHaveValue('Draft plan')
|
||||
fireEvent.keyDown(name, { key: 'Escape' })
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('放弃未保存的计划修改?')
|
||||
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
|
||||
const discard = screen.getByRole('button', { name: '放弃修改' })
|
||||
discard.focus()
|
||||
fireEvent.keyDown(discard, { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭自动监督计划' })).toHaveFocus()
|
||||
fireEvent.keyDown(discard, { key: 'Escape' })
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Draft plan')
|
||||
fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃修改' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
fireEvent.click(opener)
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('定期回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存并启用计划' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(onCreate).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: true }))
|
||||
})
|
||||
|
||||
it('opens exact plan activity even for equal names without duplicate audit and clears back to all activity', async () => {
|
||||
const activity = vi.fn(async () => [])
|
||||
window.goodbuddy.supervision.activity = activity
|
||||
const other = { ...config, id: 'heartbeat-2' }
|
||||
const props = createProps({ configs: [config, other], runs: [...runs, { ...runs[1]!, id: 'other-run', configId: other.id, error: 'Other plan failure' }] })
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '执行记录' })[1]!)
|
||||
await waitFor(() => expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 0, configId: other.id }))
|
||||
expect(screen.getByRole('combobox', { name: '监督计划' })).toHaveValue(other.id)
|
||||
expect(screen.queryByText('Other plan failure')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('模型暂时不可用')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('此计划暂无执行记录')).toBeVisible()
|
||||
const filter = screen.getByRole('combobox', { name: '监督计划' })
|
||||
filter.focus()
|
||||
fireEvent.change(filter, { target: { value: config.id } })
|
||||
expect(filter).toHaveFocus()
|
||||
await waitFor(() => expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 0, configId: config.id }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||
expect(filter).toHaveFocus()
|
||||
await waitFor(() => expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 0 }))
|
||||
expect(screen.queryByText('模型暂时不可用')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(screen.getByText('模型暂时不可用')).toBeVisible()
|
||||
expect(screen.getByText('Other plan failure')).toBeVisible()
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps the entire automatic overview with one create action out of activity (empty=%s)', async (empty) => {
|
||||
const activity = vi.fn(async () => [])
|
||||
window.goodbuddy.supervision.activity = activity
|
||||
const props = createProps(empty ? { configs: [], entries: [], runs: [], memories: [], tasks: [] } : {})
|
||||
render(<HeartbeatCenter {...props} />)
|
||||
const panel = screen.getByRole('tabpanel', { name: '自动监督' })
|
||||
for (const name of ['当前状态', '报告趋势', '运行记录']) {
|
||||
expect(within(panel).getByRole('heading', { name })).toBeVisible()
|
||||
}
|
||||
expect(panel.querySelector('.heartbeat-center__metrics')).toBeVisible()
|
||||
expect(panel.querySelector('.scope-badge')).toBeVisible()
|
||||
expect(within(panel).getAllByRole('button', { name: '创建自动监督计划' })).toHaveLength(1)
|
||||
expect(panel.querySelectorAll('#heartbeat-panel-overview .empty-state')).toHaveLength(0)
|
||||
expect(within(panel).queryByText(i18n.t('settings.empty', { ns: 'heartbeat' })) !== null).toBe(empty)
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '刷新自动监督' }))
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
await waitFor(() => expect(activity).toHaveBeenCalledOnce())
|
||||
const activityPanel = screen.getByRole('tabpanel', { name: '活动记录' })
|
||||
expect(activityPanel.querySelector('#heartbeat-panel-overview, #heartbeat-panel-plans, #heartbeat-runs-title, .heartbeat-center__metrics')).toBeNull()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '创建自动监督计划' })).not.toBeInTheDocument()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '刷新自动监督' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(activityPanel).getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(activity).toHaveBeenCalledTimes(2))
|
||||
expect(props.onRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
History,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle
|
||||
@@ -24,6 +23,8 @@ import type {
|
||||
HeartbeatUpdateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { HeartbeatSettings } from './HeartbeatSettings'
|
||||
import { defaultSupervisionTimeoutSeconds, supervisionTimeoutSecondsSchema, defaultSupervisorModelConcurrency, supervisorModelConcurrencySchema, type ApplicationSettings, type ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||
import { SupervisionReviewSettings } from './SupervisionReviewSettings'
|
||||
import { SupervisorWorkspace, type SupervisionGraphNavigation } from './SupervisorWorkspace'
|
||||
import { SupervisorActivity } from './SupervisorActivity'
|
||||
import './supervisor-workspace.css'
|
||||
@@ -36,13 +37,13 @@ import {
|
||||
type WorkspaceScope
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
type HeartbeatCenterTab =
|
||||
| 'overview'
|
||||
| 'suggestions'
|
||||
| 'history'
|
||||
| 'plans'
|
||||
|
||||
export type HeartbeatCenterProps = {
|
||||
applicationSettings?: ApplicationSettings
|
||||
applicationSettingsPending?: boolean
|
||||
applicationSettingsLocked?: boolean
|
||||
applicationSettingsError?: string
|
||||
onUpdateApplicationSettings?: (input: ApplicationSettingsUpdate) => Promise<boolean>
|
||||
onRetryApplicationSettings?: () => void
|
||||
active?: boolean
|
||||
graphNavigation?: SupervisionGraphNavigation
|
||||
configs: AssistantHeartbeatConfig[]
|
||||
@@ -95,7 +96,8 @@ export function HeartbeatCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const centerRef = useRef<HTMLElement>(null)
|
||||
const [pageTab, setPageTab] = useState<'overview' | 'graph' | 'activity' | 'settings'>(props.graphNavigation ? 'graph' : 'overview')
|
||||
const [pageTab, setPageTab] = useState<'overview' | 'graph' | 'plans' | 'activity' | 'settings'>(props.graphNavigation ? 'graph' : 'overview')
|
||||
const [activityPlanId, setActivityPlanId] = useState('')
|
||||
const [activityNavigation, setActivityNavigation] = useState<SupervisionGraphNavigation>()
|
||||
const [appliedNavigation, setAppliedNavigation] = useState(props.graphNavigation)
|
||||
if (appliedNavigation !== props.graphNavigation) {
|
||||
@@ -127,24 +129,34 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
tabs={[
|
||||
{ id: 'overview', label: t('supervisor.recap') },
|
||||
{ id: 'graph', label: t('supervisor.graph') },
|
||||
{ id: 'plans', label: t('supervisor.automatic') },
|
||||
{ id: 'activity', label: t('activity.title') },
|
||||
{ id: 'settings', label: t('supervisor.settings') }
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
role="tabpanel"
|
||||
className="supervisor-center__sections"
|
||||
id={`supervisor-panel-${pageTab}`}
|
||||
aria-labelledby={`supervisor-tab-${pageTab}`}
|
||||
>
|
||||
<div hidden={pageTab === 'settings' || pageTab === 'activity'}>
|
||||
<div hidden={pageTab !== 'overview' && pageTab !== 'graph'}>
|
||||
<SupervisorWorkspace
|
||||
graphNavigation={activityNavigation ?? props.graphNavigation}
|
||||
tab={pageTab}
|
||||
onTabChange={setPageTab}
|
||||
projects={props.projects}
|
||||
onOpenActivity={() => {
|
||||
setActivityPlanId('')
|
||||
setPageTab('activity')
|
||||
window.requestAnimationFrame(() => centerRef.current?.querySelector<HTMLElement>('#supervisor-tab-activity')?.focus())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{pageTab === 'activity' && <SupervisorActivity
|
||||
configId={activityPlanId || undefined}
|
||||
configs={props.configs}
|
||||
onPlanChange={setActivityPlanId}
|
||||
active={props.active !== false}
|
||||
projects={props.projects}
|
||||
onOpenResult={(resultId, tab) => {
|
||||
@@ -152,13 +164,75 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
setPageTab(tab)
|
||||
}}
|
||||
/>}
|
||||
{pageTab === 'settings' && <HeartbeatAutomationSettings {...props} />}
|
||||
<HeartbeatSections {...props} pageTab={pageTab}
|
||||
onOpenActivity={(id) => {
|
||||
setActivityPlanId(id)
|
||||
setPageTab('activity')
|
||||
window.requestAnimationFrame(() => centerRef.current?.querySelector<HTMLElement>('#supervisor-tab-activity')?.focus())
|
||||
}} />
|
||||
{pageTab === 'settings' && <>
|
||||
<SupervisionModelSettings {...props} />
|
||||
<SupervisionReviewSettings settings={props.applicationSettings}
|
||||
disabled={props.applicationSettingsPending || props.applicationSettingsLocked}
|
||||
onSave={props.onUpdateApplicationSettings} />
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HeartbeatAutomationSettings({
|
||||
function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const heartbeat = props.applicationSettings?.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency
|
||||
const [parallel, setParallel] = useState(String(concurrency))
|
||||
const [report, setReport] = useState(String(heartbeat))
|
||||
const [organize, setOrganize] = useState(String(supervisor))
|
||||
const [saved, setSaved] = useState({ heartbeat, supervisor, concurrency })
|
||||
if (saved.heartbeat !== heartbeat || saved.supervisor !== supervisor || saved.concurrency !== concurrency) {
|
||||
setSaved({ heartbeat, supervisor, concurrency })
|
||||
setParallel(String(concurrency))
|
||||
setReport(String(heartbeat))
|
||||
setOrganize(String(supervisor))
|
||||
}
|
||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(report)).success &&
|
||||
supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
||||
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!valid || disabled) return
|
||||
void props.onUpdateApplicationSettings?.({ heartbeatReportTimeoutSeconds: Number(report), supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) })
|
||||
}}>
|
||||
<h2>{t('timeouts.title')}</h2>
|
||||
<p>{t('timeouts.help')}</p>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.report')}
|
||||
<input type="number" min={30} max={600} step={1} value={report} disabled={disabled}
|
||||
onChange={(event) => setReport(event.target.value)} />
|
||||
</label>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.organize')}
|
||||
<input type="number" min={30} max={600} step={1} value={organize} disabled={disabled}
|
||||
onChange={(event) => setOrganize(event.target.value)} />
|
||||
</label>
|
||||
<p>{t('timeouts.transport')}</p>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.concurrency')}
|
||||
<input type="number" min={1} max={4} step={1} value={parallel} disabled={disabled}
|
||||
onChange={(event) => setParallel(event.target.value)} />
|
||||
</label>
|
||||
<p>{t('timeouts.concurrencyHelp')}</p>
|
||||
{!valid && <p role="alert">{t('timeouts.invalid')}</p>}
|
||||
{props.applicationSettingsError && <div role="alert">
|
||||
<p>{props.applicationSettingsError}</p>
|
||||
<button className="secondary-button" type="button" disabled={props.applicationSettingsPending} onClick={props.onRetryApplicationSettings}>{t('center.actions.retry')}</button>
|
||||
</div>}
|
||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(report) === heartbeat && Number(organize) === supervisor && Number(parallel) === concurrency)}>{t('timeouts.save')}</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
function HeartbeatSections({
|
||||
onOpenActivity,
|
||||
pageTab,
|
||||
configs,
|
||||
runs,
|
||||
entries,
|
||||
@@ -177,10 +251,12 @@ function HeartbeatAutomationSettings({
|
||||
loading = false,
|
||||
loadError,
|
||||
onRetryLoad
|
||||
}: HeartbeatCenterProps): React.JSX.Element {
|
||||
}: HeartbeatCenterProps & {
|
||||
pageTab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
onOpenActivity: (id: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
const [tab, setTab] = useState<HeartbeatCenterTab>('plans')
|
||||
const [pendingAction, setPendingAction] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [expandedEntryId, setExpandedEntryId] = useState<string>()
|
||||
@@ -214,15 +290,6 @@ function HeartbeatAutomationSettings({
|
||||
countFormatter.format(value)
|
||||
const formatPercent = (value: number): string =>
|
||||
percentFormatter.format(value / 100)
|
||||
const weekdayLabels = [
|
||||
t('center.weekdays.sunday'),
|
||||
t('center.weekdays.monday'),
|
||||
t('center.weekdays.tuesday'),
|
||||
t('center.weekdays.wednesday'),
|
||||
t('center.weekdays.thursday'),
|
||||
t('center.weekdays.friday'),
|
||||
t('center.weekdays.saturday')
|
||||
]
|
||||
const runStatusLabels: Record<
|
||||
AssistantHeartbeatRun['status'],
|
||||
string
|
||||
@@ -259,31 +326,6 @@ function HeartbeatAutomationSettings({
|
||||
? t('common.unknownTime')
|
||||
: dateTimeFormatter.format(date)
|
||||
}
|
||||
const recurrenceLabel = (
|
||||
config: AssistantHeartbeatConfig
|
||||
): string =>
|
||||
config.recurrence.type === 'weekly'
|
||||
? t('center.recurrence.weekly', {
|
||||
weekday: weekdayLabels[config.recurrence.weekday],
|
||||
time: config.recurrence.localTime
|
||||
})
|
||||
: t('center.recurrence.daily', {
|
||||
time: config.recurrence.localTime
|
||||
})
|
||||
const scopeLabel = (config: AssistantHeartbeatConfig): string => {
|
||||
if (config.scope.kind === 'global') {
|
||||
return t('center.scope.global')
|
||||
}
|
||||
const projectNames = config.scope.projectIds.map((projectId) => {
|
||||
const project = projects.find(
|
||||
(candidate) => candidate.id === projectId
|
||||
)
|
||||
return project
|
||||
? getProjectDisplayText(project, tWorkspace).name
|
||||
: t('settings.scope.unavailableProject')
|
||||
})
|
||||
return projectNames.join(t('settings.scope.nameSeparator'))
|
||||
}
|
||||
|
||||
const orderedEntries = useMemo(
|
||||
() => [...entries].sort(byNewest),
|
||||
@@ -317,10 +359,6 @@ function HeartbeatAutomationSettings({
|
||||
() => tasks.filter((task) => followUpTaskIds.has(task.id)),
|
||||
[followUpTaskIds, tasks]
|
||||
)
|
||||
const pendingTasks = followUpTasks.filter(
|
||||
(task) =>
|
||||
task.status !== 'completed' && task.status !== 'cancelled'
|
||||
)
|
||||
const completedTasks = followUpTasks.filter(
|
||||
(task) => task.status === 'completed'
|
||||
)
|
||||
@@ -335,9 +373,7 @@ function HeartbeatAutomationSettings({
|
||||
0
|
||||
)
|
||||
const activeConfigs = configs.filter((config) => config.enabled)
|
||||
const primaryConfig = activeConfigs[0] ?? configs[0]
|
||||
const latestEntry = orderedEntries[0]
|
||||
const attentionCount = pendingMemories.length + pendingTasks.length
|
||||
const healthPercent = percentage(completedRuns.length, terminalRuns.length)
|
||||
const memoryPercent = percentage(
|
||||
confirmedMemories.length,
|
||||
@@ -428,66 +464,27 @@ function HeartbeatAutomationSettings({
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: ReadonlyArray<{
|
||||
id: HeartbeatCenterTab
|
||||
label: string
|
||||
count?: number
|
||||
}> = [
|
||||
{ id: 'plans', label: t('center.tabs.plans') },
|
||||
{ id: 'overview', label: t('center.tabs.overview') },
|
||||
{
|
||||
id: 'suggestions',
|
||||
label: t('center.tabs.suggestions'),
|
||||
count: attentionCount
|
||||
},
|
||||
{ id: 'history', label: t('center.tabs.history') }
|
||||
]
|
||||
if (pageTab !== 'plans') return null
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('supervisor.settings')}
|
||||
className="heartbeat-center"
|
||||
aria-label={t('center.tabs.overview')}
|
||||
className="heartbeat-center supervisor-center__sections"
|
||||
>
|
||||
<div className="supervisor-workspace__action-bar">
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<ScopeBadge scope={heartbeatScope} />
|
||||
{!initialLoadBlocked && (
|
||||
<div className="supervisor-workspace__actions">
|
||||
<button
|
||||
aria-label={t('center.actions.refreshAriaLabel')}
|
||||
aria-label={t('settings.refreshPlans')}
|
||||
className="secondary-button"
|
||||
disabled={loading || pendingAction !== undefined}
|
||||
onClick={() => void runAction('refresh', onRefresh)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
{t('center.actions.refresh')}
|
||||
{t('settings.refreshPlans')}
|
||||
</button>
|
||||
{tab !== 'plans' && (primaryConfig ? (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading || pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(`run:${primaryConfig.id}`, () =>
|
||||
onRunNow(primaryConfig.id)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Play aria-hidden="true" size={14} />
|
||||
{pendingAction === `run:${primaryConfig.id}`
|
||||
? t('center.actions.running')
|
||||
: t('center.actions.runOnce')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
onClick={() => setTab('plans')}
|
||||
type="button"
|
||||
>
|
||||
{t('center.actions.configure')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -542,20 +539,10 @@ function HeartbeatAutomationSettings({
|
||||
|
||||
{!initialLoadBlocked && (
|
||||
<>
|
||||
<PageTabs
|
||||
ariaLabel={t('center.tabs.ariaLabel')}
|
||||
idPrefix="heartbeat"
|
||||
onChange={setTab}
|
||||
tabs={tabs}
|
||||
value={tab}
|
||||
/>
|
||||
|
||||
{tab === 'overview' && (
|
||||
{pageTab === 'plans' && (
|
||||
<div
|
||||
aria-labelledby="heartbeat-tab-overview"
|
||||
className="heartbeat-center__panel"
|
||||
id="heartbeat-panel-overview"
|
||||
role="tabpanel"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="heartbeat-status-title"
|
||||
@@ -583,91 +570,18 @@ function HeartbeatAutomationSettings({
|
||||
: t('center.currentStatus.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
{configs.length === 0 ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setTab('plans')}
|
||||
type="button"
|
||||
>
|
||||
{t('center.currentStatus.createPlan')}
|
||||
</button>
|
||||
}
|
||||
description={t(
|
||||
'center.currentStatus.emptyDescription'
|
||||
)}
|
||||
icon={<HeartPulse size={24} />}
|
||||
level="section"
|
||||
title={t('center.currentStatus.emptyTitle')}
|
||||
<div className="heartbeat-center__panel heartbeat-center__plans" id="heartbeat-panel-plans">
|
||||
<HeartbeatSettings
|
||||
onOpenActivity={onOpenActivity}
|
||||
heartbeats={configs}
|
||||
onCreate={onCreate}
|
||||
onRemove={onRemove}
|
||||
onRunNow={onRunNow}
|
||||
onSetPaused={onSetPaused}
|
||||
onUpdate={onUpdate}
|
||||
projects={projects}
|
||||
/>
|
||||
) : (
|
||||
<div className="heartbeat-center__config-grid">
|
||||
{configs.map((config) => (
|
||||
<article
|
||||
className="heartbeat-center__config-card"
|
||||
key={config.id}
|
||||
>
|
||||
<header>
|
||||
<span
|
||||
className={
|
||||
config.enabled
|
||||
? 'heartbeat-center__pulse-dot heartbeat-center__pulse-dot--active'
|
||||
: 'heartbeat-center__pulse-dot'
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<strong>{config.name}</strong>
|
||||
<small>
|
||||
{recurrenceLabel(config)} ·{' '}
|
||||
{scopeLabel(config)}
|
||||
</small>
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t('center.config.nextHeartbeat')}</dt>
|
||||
<dd>{formatDateTime(config.nextRunAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('center.config.lastStatus')}</dt>
|
||||
<dd>
|
||||
{config.lastStatus
|
||||
? runStatusLabels[config.lastStatus]
|
||||
: t('center.config.neverRun')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<button
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(`run:${config.id}`, () =>
|
||||
onRunNow(config.id)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('center.config.runNow')}
|
||||
</button>
|
||||
<button
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(`pause:${config.id}`, () =>
|
||||
onSetPaused(config.id, config.enabled)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{config.enabled
|
||||
? t('center.config.pause')
|
||||
: t('center.config.resume')}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl
|
||||
@@ -774,7 +688,7 @@ function HeartbeatAutomationSettings({
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="heartbeat-center__overview-grid">
|
||||
<div>
|
||||
<section
|
||||
aria-labelledby="heartbeat-trend-title"
|
||||
className="heartbeat-center__section"
|
||||
@@ -866,71 +780,14 @@ function HeartbeatAutomationSettings({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="latest-heartbeat-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="latest-heartbeat-title">
|
||||
{t('center.latest.title')}
|
||||
</h2>
|
||||
</div>
|
||||
{latestEntry && (
|
||||
<time dateTime={latestEntry.createdAt}>
|
||||
{formatDateTime(latestEntry.createdAt)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
{latestEntry ? (
|
||||
<div className="heartbeat-center__latest">
|
||||
<p>{latestEntry.summary}</p>
|
||||
{latestEntry.highlights.length > 0 && (
|
||||
<ul>
|
||||
{latestEntry.highlights.slice(0, 3).map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setTab('history')}
|
||||
type="button"
|
||||
>
|
||||
{t('center.latest.viewHistory')}
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{attentionCount > 0 && (
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setTab('suggestions')}
|
||||
type="button"
|
||||
>
|
||||
{t('center.latest.handleSuggestions', {
|
||||
count: attentionCount,
|
||||
formattedCount: formatCount(attentionCount)
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="heartbeat-center__section-empty">
|
||||
{t('center.latest.empty')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'suggestions' && (
|
||||
{pageTab === 'plans' && (
|
||||
<div
|
||||
aria-labelledby="heartbeat-tab-suggestions"
|
||||
className="heartbeat-center__panel heartbeat-center__suggestions"
|
||||
id="heartbeat-panel-suggestions"
|
||||
role="tabpanel"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="heartbeat-memory-title"
|
||||
@@ -938,7 +795,7 @@ function HeartbeatAutomationSettings({
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-memory-title">
|
||||
<h2 id="heartbeat-memory-title" tabIndex={-1}>
|
||||
{t('center.suggestions.memoryTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -1155,12 +1012,10 @@ function HeartbeatAutomationSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'history' && (
|
||||
{pageTab === 'plans' && (
|
||||
<div
|
||||
aria-labelledby="heartbeat-tab-history"
|
||||
className="heartbeat-center__panel heartbeat-center__history"
|
||||
className="heartbeat-center__panel"
|
||||
id="heartbeat-panel-history"
|
||||
role="tabpanel"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="heartbeat-reports-title"
|
||||
@@ -1168,7 +1023,7 @@ function HeartbeatAutomationSettings({
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-reports-title">
|
||||
<h2 id="heartbeat-reports-title" tabIndex={-1}>
|
||||
<History aria-hidden="true" size={16} />
|
||||
{t('center.history.timelineTitle')}
|
||||
</h2>
|
||||
@@ -1262,7 +1117,10 @@ function HeartbeatAutomationSettings({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageTab === 'plans' && <section
|
||||
aria-labelledby="heartbeat-runs-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
@@ -1331,28 +1189,8 @@ function HeartbeatAutomationSettings({
|
||||
{t('center.history.loadMoreRuns')}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>}
|
||||
|
||||
{tab === 'plans' && (
|
||||
<div
|
||||
aria-labelledby="heartbeat-tab-plans"
|
||||
className="heartbeat-center__panel heartbeat-center__plans"
|
||||
id="heartbeat-panel-plans"
|
||||
role="tabpanel"
|
||||
>
|
||||
<HeartbeatSettings
|
||||
heartbeats={configs}
|
||||
onCreate={onCreate}
|
||||
onRemove={onRemove}
|
||||
onRunNow={onRunNow}
|
||||
onSetPaused={onSetPaused}
|
||||
onUpdate={onUpdate}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HeartPulse, Pencil } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Pencil, X } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
import {
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
|
||||
type HeartbeatSettingsProps = {
|
||||
onOpenActivity?: (id: string) => void
|
||||
heartbeats: AssistantHeartbeatConfig[]
|
||||
projects: AssistantProject[]
|
||||
onCreate: (input: HeartbeatCreateInput) => Promise<void>
|
||||
@@ -37,11 +40,22 @@ export function HeartbeatSettings({
|
||||
onUpdate,
|
||||
onSetPaused,
|
||||
onRemove,
|
||||
onRunNow
|
||||
onRunNow,
|
||||
onOpenActivity
|
||||
}: HeartbeatSettingsProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
const [editingId, setEditingId] = useState<string>()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
const keepEditingRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogId = useId()
|
||||
useEffect(() => open ? activateModalFocus(() => nameRef.current) : undefined, [open])
|
||||
useEffect(() => {
|
||||
if (confirmDiscard) keepEditingRef.current?.focus()
|
||||
}, [confirmDiscard])
|
||||
const [name, setName] = useState(t('settings.defaultName'))
|
||||
const [time, setTime] = useState('09:00')
|
||||
const [timezone, setTimezone] = useState(
|
||||
@@ -97,6 +111,9 @@ export function HeartbeatSettings({
|
||||
}
|
||||
|
||||
const editHeartbeat = (heartbeat: AssistantHeartbeatConfig): void => {
|
||||
setError(undefined)
|
||||
setDirty(false)
|
||||
setOpen(true)
|
||||
setEditingId(heartbeat.id)
|
||||
setName(heartbeat.name)
|
||||
setTime(heartbeat.recurrence.localTime)
|
||||
@@ -166,6 +183,15 @@ export function HeartbeatSettings({
|
||||
hasUnavailableProject ||
|
||||
!heartbeatCreateSchema.safeParse(input()).success
|
||||
|
||||
const close = (): void => {
|
||||
if (pendingAction) return
|
||||
if (confirmDiscard) {
|
||||
setConfirmDiscard(false)
|
||||
window.requestAnimationFrame(() => nameRef.current?.focus())
|
||||
} else if (dirty) setConfirmDiscard(true)
|
||||
else setOpen(false)
|
||||
}
|
||||
|
||||
const scopeLabel = (heartbeat: AssistantHeartbeatConfig): string => {
|
||||
if (heartbeat.scope.kind === 'global') {
|
||||
return t('settings.scope.global')
|
||||
@@ -185,41 +211,50 @@ export function HeartbeatSettings({
|
||||
return (
|
||||
<div className="heartbeat-settings">
|
||||
<div className="heartbeat-settings__intro">
|
||||
<div className="inline-help-label">
|
||||
<h2>
|
||||
<HeartPulse size={15} />
|
||||
{t('settings.title')}
|
||||
</h2>
|
||||
<InlineHelp label={t('settings.title')}>{t('settings.description')}</InlineHelp>
|
||||
</div>
|
||||
<button className="primary-button" type="button" disabled={pendingAction !== undefined} onClick={() => {
|
||||
resetForm()
|
||||
setDirty(false)
|
||||
setError(undefined)
|
||||
setOpen(true)
|
||||
}}>{t('settings.createTitle')}</button>
|
||||
<p>{t('settings.scheduleHelp')}</p>
|
||||
{heartbeats.length === 0 && <p role="status">{t('settings.empty')}</p>}
|
||||
{heartbeats.length > 0 && heartbeats.every((heartbeat) => !heartbeat.enabled) && (
|
||||
<p role="status">{t('settings.allPaused')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="heartbeat-settings__editor">
|
||||
<div className="heartbeat-settings__editor-heading">
|
||||
<strong>
|
||||
{editingId
|
||||
? t('settings.editTitle')
|
||||
: t('settings.createTitle')}
|
||||
</strong>
|
||||
{editingId && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={resetForm}
|
||||
type="button"
|
||||
>
|
||||
{t('settings.cancelEdit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && createPortal(<div className="custom-task-dialog" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !confirmDiscard) close()
|
||||
}}>
|
||||
<section className="custom-task-dialog__surface" role="dialog" aria-modal="true"
|
||||
aria-busy={pendingAction !== undefined}
|
||||
aria-labelledby={`${dialogId}-title`} aria-describedby={error ? `${dialogId}-error` : undefined}
|
||||
tabIndex={-1} onKeyDown={(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close()
|
||||
} else trapTabFocus(event, event.currentTarget)
|
||||
}}>
|
||||
<header className="custom-task-dialog__header">
|
||||
<div>
|
||||
<h2 id={`${dialogId}-title`}>{t(confirmDiscard ? 'settings.discardTitle' : editingId ? 'settings.editTitle' : 'settings.createTitle')}</h2>
|
||||
</div>
|
||||
<button className="icon-button" type="button" aria-label={t('settings.close')}
|
||||
title={t('settings.close')} disabled={pendingAction !== undefined} onClick={close}>
|
||||
<X aria-hidden="true" size={17} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="custom-task-dialog__content heartbeat-settings">
|
||||
{confirmDiscard && <p>{t('settings.discardHint')}</p>}
|
||||
<div hidden={confirmDiscard} onChangeCapture={() => setDirty(true)}>
|
||||
<fieldset className="heartbeat-settings__editor" disabled={pendingAction !== undefined}>
|
||||
<p className="heartbeat-settings__empty">{t('settings.timezone', { timezone })}</p>
|
||||
<label className="heartbeat-settings__field">
|
||||
<span>{t('settings.nameLabel')}</span>
|
||||
<input
|
||||
ref={nameRef}
|
||||
maxLength={120}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
value={name}
|
||||
@@ -239,7 +274,7 @@ export function HeartbeatSettings({
|
||||
<SegmentedControl
|
||||
ariaLabel={t('settings.scope.ariaLabel')}
|
||||
disabled={pendingAction !== undefined}
|
||||
onChange={setScopeKind}
|
||||
onChange={(value) => { setScopeKind(value); setDirty(true) }}
|
||||
options={[
|
||||
{
|
||||
label: t('settings.scope.global'),
|
||||
@@ -366,6 +401,28 @@ export function HeartbeatSettings({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p>{scopeKind === 'global' ? t('settings.scope.global') : t('settings.scope.selectedProjectsSummary', {
|
||||
count: selectedProjectIds.length,
|
||||
names: selectedProjectIds.map(id => projectById.get(id)?.name ?? t('settings.scope.unavailableProject')).join(t('settings.scope.nameSeparator'))
|
||||
})}</p>
|
||||
{error && <p id={`${dialogId}-error`} className="heartbeat-settings__error" role="alert">{error}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="custom-task-dialog__actions">
|
||||
{confirmDiscard ? <>
|
||||
<button ref={keepEditingRef} className="secondary-button" type="button" onClick={close}>
|
||||
{t('settings.keepEditing')}
|
||||
</button>
|
||||
<button className="danger-solid" type="button" onClick={() => {
|
||||
setError(undefined)
|
||||
setConfirmDiscard(false)
|
||||
setOpen(false)
|
||||
}}>{t('settings.discard')}</button>
|
||||
</> : <>
|
||||
<button className="secondary-button" type="button" disabled={pendingAction !== undefined} onClick={close}>
|
||||
{t('supervisor.cancel')}
|
||||
</button>
|
||||
<button
|
||||
aria-label={
|
||||
editingId
|
||||
@@ -382,18 +439,22 @@ export function HeartbeatSettings({
|
||||
await onCreate(input())
|
||||
}
|
||||
resetForm()
|
||||
setOpen(false)
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{pendingAction === 'create'
|
||||
{pendingAction !== undefined
|
||||
? t('settings.enabling')
|
||||
: editingId
|
||||
? t('settings.save')
|
||||
: t('settings.enable')}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
</>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>, document.body)}
|
||||
{error && !open && (
|
||||
<p className="heartbeat-settings__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
@@ -438,6 +499,9 @@ export function HeartbeatSettings({
|
||||
</small>
|
||||
</span>
|
||||
<div className="heartbeat-settings__actions">
|
||||
{onOpenActivity && <button type="button" onClick={() => onOpenActivity(heartbeat.id)}>
|
||||
{t('settings.executionHistory')}
|
||||
</button>}
|
||||
<button
|
||||
aria-label={t('settings.editAriaLabel', {
|
||||
name: heartbeat.name
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionReviewBatch } from '../../shared/supervision-review-contracts'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
|
||||
export function SupervisionBatchDetails({ runId, projects = [], revision }: { runId: string; projects?: AssistantProject[]; revision?: number }) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [rows, setRows] = useState<SupervisionReviewBatch[]>([])
|
||||
const [error, setError] = useState<string>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
void window.goodbuddy.supervision.batches({ runId, offset, limit: 10 }).then(result => {
|
||||
if (!disposed) { setRows(result); setError(undefined); setLoading(false) }
|
||||
}, reason => { if (!disposed) { setError(String(reason)); setLoading(false) } })
|
||||
return () => { disposed = true }
|
||||
}, [runId, offset, revision, retry])
|
||||
const groups = useMemo(() => {
|
||||
const result = new Map<string, Map<string, SupervisionReviewBatch[]>>()
|
||||
for (const batch of rows) {
|
||||
if (!result.has(batch.projectId)) result.set(batch.projectId, new Map())
|
||||
const conversations = result.get(batch.projectId)!
|
||||
if (!conversations.has(batch.conversationId)) conversations.set(batch.conversationId, [])
|
||||
conversations.get(batch.conversationId)!.push(batch)
|
||||
}
|
||||
return [...result].map(([projectId, conversations]) => ({ projectId, conversations: [...conversations] }))
|
||||
}, [rows])
|
||||
return <section className="supervisor-activity__tree" aria-label={t('reviewSettings.facts')} aria-busy={loading}>
|
||||
<p>{t('reviewSettings.factsHelp')}</p>
|
||||
<p className="supervisor-activity__note">{t('activity.treePageHint')}</p>
|
||||
{error && <div role="alert"><p>{error}</p><button type="button" className="secondary-button" onClick={() => { setLoading(true); setRetry(value => value + 1) }}>{t('center.actions.retry')}</button></div>}
|
||||
{loading && <p role="status">{t('activity.loading')}</p>}
|
||||
{!loading && !error && !rows.length && <p>{t('activity.noSavedBatches')}</p>}
|
||||
{!loading && !error && groups.map(({ projectId, conversations }) => <details key={projectId} className="supervisor-activity__project" open>
|
||||
<summary>{projectId ? t('supervisor.projectScope', { names: projects.find(project => project.id === projectId)?.name ?? projectId }) : t('activity.unassigned')}</summary>
|
||||
{conversations.map(([conversationId, batches]) => <details key={conversationId} className="supervisor-activity__conversation">
|
||||
<summary>{conversationId ? batches[0]!.evidence.find(source => source.sourceType === 'conversation')?.title || conversationId : t('activity.projectSources')} <span>{t('activity.pageBatches', { count: batches.length })}</span></summary>
|
||||
{batches.map(batch => <details key={batch.id} className="supervisor-activity__batch">
|
||||
<summary><span className="supervisor-activity__batch-label"><span className="supervisor-activity__status--completed">{t('activity.batchSaved')}</span> · {batch.output.summary}</span></summary>
|
||||
<p>{batch.output.summary}</p>
|
||||
<p>{batch.output.changeDigest}</p>
|
||||
<ul>{batch.output.openItems.map((item, index) => <li key={index}>{item}</li>)}</ul>
|
||||
{[...batch.output.events, ...batch.output.entities, ...batch.output.entityChanges, ...batch.output.relations].map((fact, index) =>
|
||||
<article key={index}>
|
||||
<p>{'title' in fact ? fact.title : 'label' in fact ? fact.label : 'changeType' in fact ? fact.changeType : fact.relationType}</p>
|
||||
<p>{'description' in fact ? fact.description : fact.reason}</p>
|
||||
{fact.sourceReferenceIds.map(id => {
|
||||
const source = batch.evidence.find(item => item.id === id)
|
||||
return source && <details key={id}><summary>{source.title} / {String(source.locator?.messageId ?? source.sourceId)} [{String(source.locator?.start)}, {String(source.locator?.end)})</summary>
|
||||
<p>{t('reviewSettings.revision')}: {String(source.locator?.revision)}</p>
|
||||
<pre tabIndex={0} style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{source.content}</pre>
|
||||
</details>
|
||||
})}
|
||||
</article>)}
|
||||
<details><summary>{t('reviewSettings.sources')}</summary>{batch.evidence.map(source => <article key={source.id}>
|
||||
<p>{source.title}: {String(source.locator?.messageId ?? source.sourceId)} [{String(source.locator?.start)}, {String(source.locator?.end)})</p>
|
||||
<pre tabIndex={0} style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{source.content}</pre>
|
||||
</article>)}</details>
|
||||
</details>)}
|
||||
</details>)}
|
||||
</details>)}
|
||||
<nav className="supervisor-workspace__actions" aria-label={t('activity.pagination')}>
|
||||
<button type="button" className="secondary-button" disabled={loading || offset === 0} onClick={() => { setLoading(true); setOffset(offset - 10) }}>{t('reviewSettings.previousPage')}</button>
|
||||
<span>{t('activity.page', { page: offset / 10 + 1 })}</span>
|
||||
<button type="button" className="secondary-button" disabled={loading || Boolean(error) || rows.length < 10} onClick={() => { setLoading(true); setOffset(offset + 10) }}>{t('reviewSettings.nextPage')}</button>
|
||||
</nav>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { SupervisionReviewSettings } from './SupervisionReviewSettings'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
||||
import i18n, { changeUiLocale, i18nResources } from './i18n'
|
||||
|
||||
afterEach(async () => { cleanup(); await changeUiLocale('zh-CN') })
|
||||
it.each(['zh-CN', 'en-US'] as const)('shows actionable supervisor help without delivery status in %s', async locale => {
|
||||
await changeUiLocale(locale)
|
||||
render(<SupervisionReviewSettings />)
|
||||
const copy = i18nResources[locale].heartbeat
|
||||
expect(screen.getByText(copy.reviewSettings.pauseHelp)).toBeVisible()
|
||||
expect(i18n.exists('reviewSettings.pauseHelp', { ns: 'heartbeat', lng: locale })).toBe(true)
|
||||
expect(JSON.stringify(copy)).not.toMatch(/RPM|TPM|Retry-After|尚未实现|not implemented|没有按时间保存的操作审计|chronological audit log|暂不支持按分钟|Minute intervals are not supported/)
|
||||
expect(copy.timeouts.concurrencyHelp).toContain(locale === 'zh-CN' ? '仅限制监督请求' : 'This limits supervision only')
|
||||
expect(copy.reviewSettings.factsHelp).toContain(locale === 'zh-CN' ? '对照来源核对' : 'against the sources')
|
||||
})
|
||||
|
||||
it('saves validated algorithm controls and retains the draft until saved settings arrive', () => {
|
||||
const save = vi.fn()
|
||||
const defaultApplicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
render(<SupervisionReviewSettings settings={defaultApplicationSettings} onSave={save} />)
|
||||
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '0' } })
|
||||
expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } })
|
||||
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByLabelText(/单次响应容量/), { target: { value: '2048' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048 } })
|
||||
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||
import { defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
|
||||
const fields = [
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['executionSeconds', 30, 3600], ['responseKiB', 100, 16384]
|
||||
] as const
|
||||
|
||||
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
settings?: ApplicationSettings
|
||||
disabled?: boolean
|
||||
onSave?: (value: ApplicationSettingsUpdate) => void | Promise<unknown>
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview,
|
||||
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024 }
|
||||
const [saved, setSaved] = useState(current)
|
||||
const [draft, setDraft] = useState(current)
|
||||
if (fields.some(([key]) => saved[key] !== current[key])) { setSaved(current); setDraft(current) }
|
||||
const valid = supervisionReviewSettingsSchema.safeParse(draft).success
|
||||
const locked = disabled || !settings || !onSave
|
||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (valid && !locked) void onSave?.({ supervisionReview: draft })
|
||||
}}>
|
||||
<h2>{t('reviewSettings.title')}</h2>
|
||||
<p>{t('reviewSettings.help')}</p>
|
||||
{fields.map(([key, min, max]) => <label key={key} className="heartbeat-settings__field">
|
||||
{t(`reviewSettings.${key}`)} ({min}..{max})
|
||||
<input type="number" min={min} max={max} step={1} value={draft[key]} disabled={locked}
|
||||
onChange={event => setDraft({ ...draft, [key]: Number(event.target.value) })} />
|
||||
</label>)}
|
||||
<p>{t('reviewSettings.pauseHelp')}</p>
|
||||
{!valid && <p role="alert">{t('reviewSettings.invalid')}</p>}
|
||||
<button type="submit" className="primary-button" disabled={locked || !valid || fields.every(([key]) => draft[key] === current[key])}>{t('reviewSettings.save')}</button>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionActivity } from '../../shared/supervision-contracts'
|
||||
|
||||
const phases = ['collecting', 'extracting', 'summarizing', 'saving'] as const
|
||||
|
||||
export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const progress = row.reviewProgress!
|
||||
const current = progress.phase ? phases.indexOf(progress.phase) : -1
|
||||
return <div className="supervisor-activity__workflow">
|
||||
<ol className="supervisor-activity__steps" aria-label={t('activity.stages')}>
|
||||
{phases.map((phase, index) => {
|
||||
// Source coverage is not publication. Historical runs may have no saved phase.
|
||||
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 < 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>
|
||||
<span><strong>{t(`activity.phases.${phase}`)}</strong><small>{t(`activity.stageStates.${state}`)}</small></span>
|
||||
</li>
|
||||
})}
|
||||
</ol>
|
||||
<p className="supervisor-activity__coverage">{t('reviewSettings.progress', { batches: progress.batches, characters: progress.characters, remaining: progress.remainingSources })}</p>
|
||||
{progress.navigationNodes !== undefined && <p className="supervisor-activity__note">{t('activity.navigationSaved', { count: progress.navigationNodes })}</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>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,16 +1,58 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, 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'
|
||||
import './i18n'
|
||||
import { SupervisionBatchDetails } from './SupervisionBatchDetails'
|
||||
import { changeUiLocale, i18nResources } from './i18n'
|
||||
|
||||
const row: Activity = {
|
||||
id: 'run', kind: 'heartbeat', trigger: 'scheduled', status: 'running', scope: { kind: 'global' },
|
||||
startedAt: '2026-09-23T10:00:00Z', completedAt: null, timeRange: null, error: null,
|
||||
summary: 'Saved heartbeat', resultId: null, heartbeatStatus: 'completed', supervisionStatus: 'running'
|
||||
}
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() })
|
||||
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 => {
|
||||
await changeUiLocale(locale)
|
||||
const copy = i18nResources[locale].heartbeat
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
|
||||
...row, status: 'failed', supervisionStatus: 'failed', error: 'Provider HTTP 429: Retry-After 30s',
|
||||
reviewProgress: { runId: 'saved-run', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
|
||||
}]), resume: vi.fn() } })
|
||||
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
expect(await screen.findByText(copy.reviewSettings.runError)).toBeVisible()
|
||||
const details = screen.getByText('Provider HTTP 429: Retry-After 30s')
|
||||
expect(details).not.toBeVisible()
|
||||
fireEvent.click(screen.getByText(copy.reviewSettings.diagnostics))
|
||||
expect(details.closest('details')).toHaveAttribute('open')
|
||||
expect(details).toHaveStyle({ userSelect: 'text' })
|
||||
expect(screen.getByText(copy.activity.status.failed)).toBeVisible()
|
||||
expect(screen.getByText(copy.activity.description)).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: copy.reviewSettings.resume })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('shows durable progress, pauses and continues the same run, and expands retained facts', async () => {
|
||||
const progress = { runId: 'saved-run', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
|
||||
const activity = vi.fn().mockResolvedValue([{ ...row, reviewProgress: progress }])
|
||||
const pause = vi.fn(async () => { activity.mockResolvedValue([{ ...row, status: 'paused', supervisionStatus: 'paused', reviewProgress: progress }]) })
|
||||
const resume = vi.fn(async () => undefined)
|
||||
const batches = vi.fn().mockResolvedValue([{ id: 'leaf', projectId: 'project', conversationId: 'conversation',
|
||||
evidence: [{ id: 'source', sourceId: 'conversation', title: 'Message', content: 'Retained quotation', locator: { messageId: 'message-id', start: 0, end: 18, revision: 'revision' } }],
|
||||
output: { summary: 'Leaf navigation', changeDigest: '', openItems: [], entities: [], relations: [], entityChanges: [],
|
||||
events: [{ title: 'Decision', description: 'Retained fact', sourceReferenceIds: ['source'] }] } }])
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity, pause, resume, batches } })
|
||||
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
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(activity).toHaveBeenCalledTimes(3))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '已保留事实与来源' }))
|
||||
expect(await screen.findByText('Retained fact')).toBeInTheDocument()
|
||||
expect(batches).toHaveBeenCalledWith({ runId: 'saved-run', offset: 0, limit: 10 })
|
||||
expect(screen.getAllByText(/message-id \[0, 18\)/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows no-change execution without a new result action', async () => {
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
|
||||
@@ -81,5 +123,102 @@ it('shows actual status, scope and result destinations without unsupported cance
|
||||
fireEvent.click(screen.getByRole('button', { name: '在图谱中查看' }))
|
||||
expect(open).toHaveBeenLastCalledWith('old-result', 'graph')
|
||||
expect(screen.queryByRole('button', { name: /取消/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('用户触发')).toBeVisible()
|
||||
expect(screen.getByText(/全局 · 用户触发/)).toBeVisible()
|
||||
})
|
||||
|
||||
it.each(['summarizing', 'saving', undefined] as const)('renders saved phase %s without mistaking zero remaining for publication', async phase => {
|
||||
const resume = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
|
||||
...row, status: 'failed', supervisionStatus: 'failed', error: 'Missing arrays\n'.repeat(100),
|
||||
reviewProgress: { runId: 'saved-run', phase, navigationNodes: 1, batches: 6, characters: 4515, sources: 32, remainingSources: 0, complete: false,
|
||||
settings: { pageSize: 50, batchCharacters: 8000, batchMessages: 20, executionSeconds: 300, timeoutSeconds: 240, concurrency: 1 } }
|
||||
}]), resume } })
|
||||
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
await screen.findByText(/已保存 6 批/)
|
||||
expect(container.querySelector('[data-phase="saving"]')).toHaveAttribute('data-state', phase === 'saving' ? 'failed' : phase ? 'pending' : 'unknown')
|
||||
expect(container.querySelector('[data-phase="summarizing"]')).toHaveAttribute('data-state', phase === 'saving' ? 'completed' : phase ? 'failed' : 'unknown')
|
||||
expect(container.querySelector('.supervisor-activity__diagnostics')).not.toHaveAttribute('open')
|
||||
expect(container.querySelector('.supervisor-activity__advanced')).not.toHaveAttribute('open')
|
||||
expect(screen.getByText('本次回顾的已保存配置')).not.toBeVisible()
|
||||
const action = screen.getByRole('button', { name: phase === 'summarizing' ? '继续合并摘要' : phase === 'saving' ? '重试发布' : '继续已保存回顾' })
|
||||
expect(action.compareDocumentPosition(container.querySelector('.supervisor-activity__diagnostics')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(action)
|
||||
await waitFor(() => expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' }))
|
||||
})
|
||||
|
||||
it('pages the saved project/conversation hierarchy on demand', async () => {
|
||||
const batch = (index: number) => ({ id: `batch-${index}`, projectId: `project-${index < 5 ? 1 : 2}`, conversationId: `conversation-${index < 5 ? 1 : 2}`,
|
||||
evidence: [{ id: `source-${index}`, sourceType: 'conversation', title: `Conversation ${index < 5 ? 1 : 2}`, content: 'Source text', sourceId: 'conversation' }],
|
||||
output: { summary: `Saved batch ${index}`, changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } })
|
||||
const batches = vi.fn().mockResolvedValueOnce(Array.from({ length: 10 }, (_, index) => batch(index))).mockResolvedValueOnce([batch(10)])
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{ ...row,
|
||||
reviewProgress: { runId: 'saved-run', batches: 11, characters: 4515, sources: 32, remainingSources: 0, complete: false, phase: 'summarizing' }
|
||||
}]), batches } })
|
||||
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
const toggle = await screen.findByRole('button', { name: '已保留事实与来源' })
|
||||
expect(batches).not.toHaveBeenCalled()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await screen.findByText(/Conversation 1/, { selector: 'summary' })
|
||||
expect(screen.getByText('Saved batch 0', { exact: false, selector: '.supervisor-activity__batch-label' })).not.toBeVisible()
|
||||
fireEvent.click(screen.getByText(/Conversation 1/, { selector: 'summary' }))
|
||||
expect(screen.getByText('Saved batch 0', { exact: false, selector: '.supervisor-activity__batch-label' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }))
|
||||
await waitFor(() => expect(batches).toHaveBeenLastCalledWith({ runId: 'saved-run', offset: 10, limit: 10 }))
|
||||
await screen.findByText(/Saved batch 10/, { selector: '.supervisor-activity__batch-label' })
|
||||
expect(screen.queryByText(/Saved batch 0/, { selector: '.supervisor-activity__batch-label' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('retains hierarchy expansion on saved-batch refresh and retries a failed page without mislabelling old rows', async () => {
|
||||
const batch = { id: 'batch', projectId: 'project', conversationId: 'conversation', evidence: [],
|
||||
output: { summary: 'Saved summary', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } }
|
||||
const rows = Array.from({ length: 10 }, (_, index) => ({ ...batch, id: `batch-${index}` }))
|
||||
const batches = vi.fn().mockResolvedValueOnce(rows).mockResolvedValueOnce([...rows])
|
||||
.mockRejectedValueOnce(new Error('Page read failed')).mockResolvedValueOnce([{ ...batch, id: 'last', output: { ...batch.output, summary: 'Last batch' } }])
|
||||
vi.stubGlobal('goodbuddy', { supervision: { batches } })
|
||||
const view = render(<SupervisionBatchDetails runId="run" revision={10} />)
|
||||
const conversation = await screen.findByText(/conversation/, { selector: 'summary' })
|
||||
fireEvent.click(conversation)
|
||||
view.rerender(<SupervisionBatchDetails runId="run" revision={11} />)
|
||||
await waitFor(() => expect(batches).toHaveBeenCalledTimes(2))
|
||||
expect(conversation.closest('details')).toHaveAttribute('open')
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }))
|
||||
await screen.findByText('Error: Page read failed')
|
||||
expect(screen.queryByText(/Saved summary/, { selector: '.supervisor-activity__batch-label' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await screen.findByText(/Last batch/, { selector: '.supervisor-activity__batch-label' })
|
||||
expect(batches).toHaveBeenLastCalledWith({ runId: 'run', offset: 10, limit: 10 })
|
||||
})
|
||||
|
||||
it.each(['collecting', 'extracting', 'summarizing', 'saving'] as const)('shows running %s as active with later stages pending', async phase => {
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{ ...row,
|
||||
reviewProgress: { runId: 'run', phase, batches: 6, characters: 4515, sources: 32, remainingSources: 0, complete: false, inFlight: 1 }
|
||||
}]) } })
|
||||
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
await screen.findByRole('button', { name: '暂停回顾' })
|
||||
expect(container.querySelector(`[data-phase="${phase}"]`)).toHaveAttribute('data-state', 'running')
|
||||
expect(container.querySelector(`[data-phase="${phase}"]`)).toHaveAttribute('aria-current', 'step')
|
||||
expect(container.querySelector('[data-phase="saving"]')).toHaveAttribute('data-state', phase === 'saving' ? 'running' : 'pending')
|
||||
})
|
||||
|
||||
it('resets pagination on a plan change and ignores the previous page response', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolve!: (value: Activity[]) => void
|
||||
const activity = vi.fn().mockResolvedValueOnce(Array.from({ length: 50 }, (_, index) => ({ ...row, id: String(index) })))
|
||||
.mockImplementationOnce(() => new Promise<Activity[]>(done => { resolve = done }))
|
||||
.mockResolvedValue([])
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity } })
|
||||
const props = { active: true, projects: [], onOpenResult: vi.fn() }
|
||||
const view = render(<SupervisorActivity {...props} configId="first-plan" />)
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
fireEvent.click(screen.getByRole('button', { name: '更早记录' }))
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 50, configId: 'first-plan' })
|
||||
view.rerender(<SupervisorActivity {...props} configId="second-plan" />)
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 0, configId: 'second-plan' })
|
||||
await act(async () => resolve([{ ...row, summary: 'Wrong plan result' }]))
|
||||
expect(screen.queryByText('Wrong plan result')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('此计划暂无执行记录')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { History, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { AssistantHeartbeatConfig, AssistantProject } from '../../shared/assistant-contracts'
|
||||
import type { SupervisionActivity } from '../../shared/supervision-contracts'
|
||||
import { EmptyState, ScopeBadge } from './WorkspacePrimitives'
|
||||
import { SupervisionBatchDetails } from './SupervisionBatchDetails'
|
||||
import { SupervisionReviewStages } from './SupervisionReviewStages'
|
||||
|
||||
export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
export function SupervisorActivity({ active, projects, onOpenResult, configId, configs = [], onPlanChange }: {
|
||||
configId?: string
|
||||
configs?: AssistantHeartbeatConfig[]
|
||||
onPlanChange?: (id: string) => void
|
||||
active: boolean
|
||||
projects: AssistantProject[]
|
||||
onOpenResult: (resultId: string, tab: 'overview' | 'graph') => void
|
||||
@@ -17,6 +22,18 @@ export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [reading, setReading] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [expanded, setExpanded] = useState<string>()
|
||||
const [pending, setPending] = useState<string>()
|
||||
const filterRef = useRef<HTMLSelectElement>(null)
|
||||
const [appliedConfigId, setAppliedConfigId] = useState(configId)
|
||||
if (appliedConfigId !== configId) {
|
||||
setAppliedConfigId(configId)
|
||||
setOffset(0)
|
||||
setRows([])
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
setExpanded(undefined)
|
||||
}
|
||||
const api = window.goodbuddy?.supervision
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
@@ -26,7 +43,7 @@ export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
setReading(true)
|
||||
try {
|
||||
if (!api?.activity) throw new Error(t('supervisor.unavailable'))
|
||||
const result = await api.activity({ limit: 50, offset })
|
||||
const result = await api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) })
|
||||
if (disposed) return
|
||||
setRows(result)
|
||||
setError(undefined)
|
||||
@@ -43,7 +60,7 @@ export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
}
|
||||
timer = setTimeout(() => void load(), 0)
|
||||
return () => { disposed = true; clearTimeout(timer) }
|
||||
}, [active, api, offset, refresh, t])
|
||||
}, [active, api, offset, refresh, t, configId])
|
||||
const reload = (nextOffset = offset) => {
|
||||
setLoading(true)
|
||||
setError(undefined)
|
||||
@@ -51,9 +68,23 @@ export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
setRefresh((value) => value + 1)
|
||||
}
|
||||
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage)
|
||||
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={{ kind: 'mixed' }} />
|
||||
<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)}>
|
||||
<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>)}
|
||||
</select>
|
||||
</label>}
|
||||
{configId && onPlanChange && <button className="secondary-button" type="button" onClick={() => {
|
||||
onPlanChange('')
|
||||
filterRef.current?.focus()
|
||||
}}>{t('activity.clearFilter')}</button>}
|
||||
<button className="secondary-button" type="button" disabled={loading || reading || !active} onClick={() => reload()}>
|
||||
<RefreshCw size={14} aria-hidden="true" />{t('center.actions.refresh')}
|
||||
</button>
|
||||
@@ -63,29 +94,59 @@ export function SupervisorActivity({ active, projects, onOpenResult }: {
|
||||
<button className="secondary-button" type="button" onClick={() => reload()}>{t('center.actions.retry')}</button>
|
||||
</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('activity.empty')} description={t('activity.emptyHint')} />
|
||||
: !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}`}>
|
||||
<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>
|
||||
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>{t('activity.trigger')}</dt><dd>{t(`activity.triggers.${row.trigger}`)}</dd></div>
|
||||
<div><dt>{t('supervisor.scope')}</dt><dd>{!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(', ') })}</dd></div>
|
||||
<div><dt>{t('activity.started')}</dt><dd><time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time></dd></div>
|
||||
<div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? <time dateTime={row.completedAt} title={row.completedAt}>{date(row.completedAt)}</time> : t('common.unavailable')}</dd></div>
|
||||
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
|
||||
</dl>
|
||||
<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>
|
||||
{row.reviewProgress && <SupervisionReviewStages row={row} />}
|
||||
{!row.reviewProgress && <>
|
||||
<p className="supervisor-activity__stages">
|
||||
{row.heartbeatStatus && <span>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</span>}
|
||||
<span>{t('activity.supervisionStage')}: {row.supervisionStatus ? t(`activity.status.${row.supervisionStatus}`) : t('activity.notRecorded')}</span>
|
||||
</p>
|
||||
{row.error && <p className="heartbeat-center__error">{row.error}</p>}
|
||||
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
||||
</>}
|
||||
{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>}
|
||||
<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.error && <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>
|
||||
<pre tabIndex={0} aria-label={t('reviewSettings.diagnostics')} style={{ userSelect: 'text' }}>{row.error}</pre>
|
||||
</details>
|
||||
</div>}
|
||||
{row.resultId && <div className="supervisor-workspace__actions">
|
||||
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'overview')}>{t('activity.openReview')}</button>
|
||||
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'graph')}>{t('supervisor.viewInGraph')}</button>
|
||||
</div>}
|
||||
{row.reviewProgress && expanded === row.id && <div id={`batches-${row.id}`}><SupervisionBatchDetails key={row.reviewProgress.runId} runId={row.reviewProgress.runId} projects={projects} revision={row.reviewProgress.batches} /></div>}
|
||||
<details className="supervisor-activity__advanced"><summary>{t('activity.runDetails')}</summary>
|
||||
<dl>
|
||||
<div><dt>{t('activity.started')}</dt><dd>{date(row.startedAt)}</dd></div>
|
||||
<div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? date(row.completedAt) : t('common.unavailable')}</dd></div>
|
||||
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
|
||||
</dl>
|
||||
{row.reviewProgress && row.heartbeatStatus && <p>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</p>}
|
||||
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
||||
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
|
||||
<p>{t('reviewSettings.configurationValues', row.reviewProgress.settings)}</p>
|
||||
</details>}
|
||||
</details>
|
||||
</article>
|
||||
</li>)}</ol>}
|
||||
<nav className="supervisor-workspace__actions" aria-label={t('activity.pagination')}>
|
||||
|
||||
@@ -6,6 +6,44 @@ 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: [] }
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it('keeps dated historical content and exact graph selection when configuring and running a new review', async () => {
|
||||
const old = { ...result, id: 'old', summary: 'Earlier conclusion.\n\nImportant final conclusion.', changeDigest: 'Confirmed change', openItems: ['Pending decision'], createdAt: '2026-08-22T00:00:00.000Z' }
|
||||
let finish!: () => void
|
||||
const run = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result, old], graph, run } } as never
|
||||
const onTabChange = vi.fn()
|
||||
const onOpenActivity = vi.fn()
|
||||
const project = { id: 'project', name: 'New project', description: '', status: 'active', kind: 'user', rootPath: 'C:\\project', executionSpace: { kind: 'local', rootPath: 'C:\\project' }, defaultWorkMode: 'ask', createdAt: result.createdAt, updatedAt: result.createdAt } as const
|
||||
render(<SupervisorWorkspace projects={[project]} onTabChange={onTabChange} onOpenActivity={onOpenActivity} />)
|
||||
const history = await screen.findByLabelText('历史结果')
|
||||
await waitFor(() => expect(history).toBeEnabled())
|
||||
fireEvent.change(history, { target: { value: 'old' } })
|
||||
await waitFor(() => expect(history).toBeEnabled())
|
||||
expect(history).toHaveValue('old')
|
||||
expect(screen.queryByText('最近一次成功回顾')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Important final conclusion.')).toBeVisible()
|
||||
expect(screen.getByText('Confirmed change')).toBeVisible()
|
||||
expect(screen.getByText('Pending decision')).toBeVisible()
|
||||
const recap = screen.getByRole('article')
|
||||
const frozenText = recap.textContent
|
||||
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
|
||||
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: project.id } })
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
|
||||
expect(onTabChange).toHaveBeenCalledWith('graph')
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
expect(screen.getByText('Important final conclusion.')).toBeVisible()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理')
|
||||
expect(screen.getByRole('button', { name: '回顾整理中…' })).toBeDisabled()
|
||||
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())
|
||||
})
|
||||
it('graph navigation discards a late overview before it can request the old graph', async () => {
|
||||
let resolveOverview!: (value: unknown) => void
|
||||
const overview = vi.fn().mockImplementationOnce(() => new Promise(resolve => { resolveOverview = resolve })).mockResolvedValue([result])
|
||||
|
||||
@@ -16,9 +16,10 @@ type Selection = { kind: 'event' | 'entity' | 'relation'; id: string }
|
||||
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph' }
|
||||
type Props = {
|
||||
graphNavigation?: SupervisionGraphNavigation
|
||||
tab?: 'overview' | 'graph' | 'activity' | 'settings'
|
||||
tab?: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
projects?: AssistantProject[]
|
||||
onTabChange?: (tab: 'overview' | 'graph' | 'activity' | 'settings') => void
|
||||
onOpenActivity?: () => void
|
||||
onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void
|
||||
}
|
||||
const emptyGraph: SupervisionGraphView = {
|
||||
storyLine: null,
|
||||
@@ -45,7 +46,8 @@ export function SupervisorWorkspace({
|
||||
graphNavigation,
|
||||
tab = 'overview',
|
||||
projects = [],
|
||||
onTabChange
|
||||
onTabChange,
|
||||
onOpenActivity
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const graphId = useId()
|
||||
@@ -67,6 +69,7 @@ export function SupervisorWorkspace({
|
||||
const [projectId, setProjectId] = useState('global')
|
||||
const [days, setDays] = useState(7)
|
||||
const [lastRequest, setLastRequest] = useState<SupervisionRunRequest>()
|
||||
const [pausedReview, setPausedReview] = useState(false)
|
||||
const [resultId, setResultId] = useState<string | undefined>(graphNavigation?.resultId)
|
||||
const [appliedNavigation, setAppliedNavigation] = useState(graphNavigation)
|
||||
if (appliedNavigation !== graphNavigation) {
|
||||
@@ -187,12 +190,14 @@ export function SupervisorWorkspace({
|
||||
}
|
||||
}
|
||||
setLastRequest(request)
|
||||
setPausedReview(false)
|
||||
setPending('run')
|
||||
setErrorAction('run')
|
||||
setError(undefined)
|
||||
try {
|
||||
await api.run(request)
|
||||
const outcome = await api.run(request) as { status?: string } | undefined
|
||||
if (generation !== loadGeneration.current) return
|
||||
setPausedReview(outcome?.status === 'paused')
|
||||
selectedResult.current = undefined
|
||||
setPending(undefined)
|
||||
await refresh()
|
||||
@@ -412,7 +417,7 @@ export function SupervisorWorkspace({
|
||||
const busy = !!api && (loading || pending !== undefined)
|
||||
|
||||
return (
|
||||
<div className="supervisor-workspace" aria-busy={busy}>
|
||||
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
|
||||
{!api ? (
|
||||
<div role="alert">
|
||||
<EmptyState
|
||||
@@ -444,6 +449,10 @@ export function SupervisorWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__actions" role="status">
|
||||
<span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
|
||||
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
|
||||
</div>}
|
||||
{error && (
|
||||
<div className="supervisor-workspace__inline-error" role="alert">
|
||||
<strong>{t('common.operationFailed')}</strong>
|
||||
@@ -470,7 +479,8 @@ export function SupervisorWorkspace({
|
||||
)}
|
||||
{tab === 'overview' && (
|
||||
<>
|
||||
<div className="supervisor-workspace__toolbar">
|
||||
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
|
||||
<strong>{t('supervisor.newReview')}</strong>
|
||||
<label>
|
||||
{t('supervisor.scope')}
|
||||
<select
|
||||
@@ -522,52 +532,56 @@ export function SupervisorWorkspace({
|
||||
<p className="supervisor-workspace__muted">
|
||||
{t('supervisor.sourcesHint')}
|
||||
</p>
|
||||
{results.length > 0 && <div className="supervisor-workspace__result-navigation">
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
<select
|
||||
value={resultId ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => void refresh(event.target.value)}
|
||||
>
|
||||
{!results.some((item) => item.id === resultId) && <option value={resultId ?? ''}>{t('supervisor.loading')}</option>}
|
||||
{results.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{date(item.createdAt)} · {scopeText(item.scope)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{latest && <button className="secondary-button" onClick={() => onTabChange?.('graph')}>
|
||||
{t('supervisor.graph')}
|
||||
</button>}
|
||||
</div>}
|
||||
{latest ? (
|
||||
<article className="supervisor-workspace__recap">
|
||||
<div className="supervisor-workspace__section-heading">
|
||||
<h2>{t('supervisor.latest')}</h2>
|
||||
<time>{date(latest.createdAt)}</time>
|
||||
<time dateTime={latest.createdAt}>{date(latest.createdAt)}</time>
|
||||
</div>
|
||||
<p>
|
||||
{scopeText(latest.scope)} · {date(latest.timeRange.from)} –{' '}
|
||||
{date(latest.timeRange.to)}
|
||||
</p>
|
||||
<p className="supervisor-workspace__summary">
|
||||
{latest.summary}
|
||||
</p>
|
||||
{latest.changeDigest && <p>{latest.changeDigest}</p>}
|
||||
{latest.openItems.length > 0 && (
|
||||
<>
|
||||
<h3>{t('supervisor.openItems')}</h3>
|
||||
<ul>
|
||||
{latest.openItems.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => onTabChange?.('graph')}
|
||||
>
|
||||
{t('supervisor.graph')}
|
||||
</button>
|
||||
{results.length > 1 && (
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
<select
|
||||
value={latest.id}
|
||||
disabled={pending !== undefined}
|
||||
onChange={(event) => { setResultId(event.target.value); void refresh(event.target.value) }}
|
||||
>
|
||||
{results.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{date(item.createdAt)} · {scopeText(item.scope)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="supervisor-workspace__prose">
|
||||
<h3>{t('supervisor.summary')}</h3>
|
||||
{latest.summary.split(/\r?\n\s*\r?\n/u).map((paragraph, index) => (
|
||||
<p className="supervisor-workspace__summary" key={index}>{paragraph}</p>
|
||||
))}
|
||||
{latest.changeDigest && <>
|
||||
<h3>{t('supervisor.changes')}</h3>
|
||||
<p className="supervisor-workspace__summary">{latest.changeDigest}</p>
|
||||
</>}
|
||||
{latest.openItems.length > 0 && (
|
||||
<>
|
||||
<h3>{t('supervisor.openItems')}</h3>
|
||||
<ul>
|
||||
{latest.openItems.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
!loading &&
|
||||
@@ -1160,7 +1174,6 @@ export function SupervisorWorkspace({
|
||||
{t('supervisor.label')}
|
||||
<input
|
||||
value={revision}
|
||||
maxLength={240}
|
||||
onChange={(event) =>
|
||||
setRevision(event.target.value)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,61 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { heartbeat as chineseHeartbeat } from '../zh-CN/heartbeat'
|
||||
|
||||
export const heartbeat = {
|
||||
reviewSettings: {
|
||||
title: 'Review algorithm',
|
||||
responseKiB: 'Response capacity, KiB (default 1024)',
|
||||
save: 'Save review algorithm',
|
||||
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
|
||||
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
|
||||
batchMessages: 'Messages per batch (default 20)', executionSeconds: 'Time before pausing, seconds (default 300)',
|
||||
pauseHelp: 'When the execution soft budget is reached, active batches finish and save before pausing. Continue from Activity records.',
|
||||
invalid: 'Enter whole numbers within the displayed ranges.',
|
||||
pause: 'Pause review', resume: 'Continue saved review', facts: 'Retained facts and sources',
|
||||
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.',
|
||||
revision: 'Source revision', sources: 'All covered source fragments',
|
||||
previousPage: 'Previous page', nextPage: 'Next page',
|
||||
extracting: 'Reviewing source batches', summarizing: 'Building navigation summaries', inFlight: '{{count}} queued or running model tasks',
|
||||
configuration: 'Saved review configuration',
|
||||
configurationValues: 'Read {{pageSize}} sources at a time; batch size {{batchCharacters}}, up to {{batchMessages}} messages; pause after {{executionSeconds}}s; model timeout {{timeoutSeconds}}s; up to {{concurrency}} simultaneous tasks. Tasks also share the current supervision concurrency limit.',
|
||||
progress: '{{batches}} saved batches · {{characters}} characters reviewed · {{remaining}} sources remaining',
|
||||
identityError: 'The model returned an invalid entity reference. This batch was not saved; previously saved batches remain available.',
|
||||
runError: 'This run failed. Review the error details before continuing; saved batches remain available.',
|
||||
diagnostics: 'Error details'
|
||||
},
|
||||
timeouts: {
|
||||
title: 'Model timeouts and concurrency',
|
||||
report: 'Heartbeat report timeout (seconds)',
|
||||
organize: 'Supervisor organize timeout (seconds)',
|
||||
help: 'Each model timeout defaults to 240 seconds, adjustable from 30 to 600. Reports freeze the timeout when requested. Reviews save the organize timeout at creation and retain it when continued.',
|
||||
transport: 'These limits cover model execution only, not collection or saving. Direct model transport has an independent 600-second timeout; these settings do not extend it. Provider or Runtime limits may stop a request earlier.',
|
||||
concurrency: 'Supervision model concurrency',
|
||||
concurrencyHelp: 'Heartbeat reports and supervisor organizing share this limit: 1 by default, adjustable from 1 to 4. Queue time does not count toward model timeouts. Lowering the limit affects new dispatches without interrupting active requests. This limits supervision only; ordinary chat is unaffected but still shares upstream model capacity.',
|
||||
invalid: 'Enter whole numbers from 30 to 600 for each timeout and from 1 to 4 for concurrency.',
|
||||
save: 'Save model settings'
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity', description: 'Saved manual reviews and automatic supervision runs. Updates while this tab is open. Entity and relation edits do not have a chronological audit log.',
|
||||
loading: 'Loading activity', loadingHint: 'Reading saved execution records.', empty: 'No activity recorded', emptyHint: 'Run a manual review or configure automatic supervision in Settings.',
|
||||
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' },
|
||||
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
|
||||
navigationSaved: '{{count}} navigation merges saved',
|
||||
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
|
||||
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
|
||||
noSavedBatches: 'No batches saved yet.', unassigned: 'No project', projectSources: 'Project sources',
|
||||
pageBatches: '{{count}} batches on this page', batchSaved: 'Saved',
|
||||
refreshOverview: 'Refresh run overview',
|
||||
planFilter: 'Supervision plan',
|
||||
allPlans: 'All plans and manual reviews',
|
||||
unavailablePlan: 'Unavailable plan',
|
||||
clearFilter: 'Clear filter',
|
||||
filteredEmpty: 'No executions for this plan',
|
||||
filteredEmptyHint: 'Run the plan from Automatic supervision, or clear the filter to view other activity.',
|
||||
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)' },
|
||||
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue' },
|
||||
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',
|
||||
@@ -18,17 +68,19 @@ export const heartbeat = {
|
||||
unknownTime: 'Unknown time'
|
||||
},
|
||||
supervisor: {
|
||||
automatic: 'Automatic supervision',
|
||||
projectScope: 'Project: {{names}}',
|
||||
canvasTitle: 'How the work took shape', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
|
||||
visibleCounts: '{{events}} events / {{entities}} entities on canvas', showAll: 'Show all connections', connections: 'Graph connections',
|
||||
loadingHint: 'Reading saved reviews, events, and sources.', unavailableHint: 'Reopen the application to try again. Saved reviews remain on this device.',
|
||||
selection: 'Graph selection', canvas: 'Story graph canvas', inspector: 'Details and sources',
|
||||
counts: '{{events}} events · {{entities}} entities', canvasCaption: 'Events are evenly spaced in time order, not by elapsed time. Each batch shows up to 8 events and 6 entities; select from the list to switch batches and read full names. Scroll the canvas horizontally on narrow screens. Browsing events does not restore historical entity state.',
|
||||
counts: '{{events}} events · {{entities}} entities', canvasCaption: 'Events are evenly spaced in time order, not by elapsed time. Each batch shows up to 8 events and 6 entities; select from the list to switch batches and read full names. Scroll the canvas horizontally on narrow screens. Entity states reflect the selected review result.',
|
||||
legendLabel: 'Graph legend', eventImpact: 'Event impact', playback: 'Browse events', previousStage: 'Previous event', nextStage: 'Next event',
|
||||
navigation: 'Supervisor views', recap: 'Work review', graph: 'Story graph', settings: 'Settings',
|
||||
viewInGraph: 'View in graph',
|
||||
unavailable: 'Supervisor service is unavailable', loading: 'Loading Supervisor review', scope: 'Review scope', period: 'Time range', days: '{{count}} days',
|
||||
sourcesHint: 'Manual review reprocesses the selected period, including previously reviewed sources. Automatic supervision processes only new or changed source portions. Deleting a report does not reset automatic progress or rebuild the graph.', latest: 'Latest successful review', openItems: 'Open items', history: 'Review history',
|
||||
sourcesHint: 'Choose a scope and period for a new review. The selected result and its covered period appear below.', latest: 'Supervision result', openItems: 'Open items', history: 'Review history',
|
||||
newReview: 'New review', summary: 'Work summary', changes: 'Changes', runningHint: 'A new review is running. The existing result remains available to read.',
|
||||
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an automatic supervision plan.', run: 'Review current progress', running: 'Reviewing…', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
|
||||
events: 'Time events', entities: 'Knowledge entities', relations: 'Entity relations', eventSources: 'Event sources', sources: 'Related sources', noSources: 'No related sources are available.', selectHint: 'Select an event, entity, or relation to inspect it.', sourceSnapshot: 'Source details', sourceMissing: 'Source not found',
|
||||
@@ -62,7 +114,7 @@ export const heartbeat = {
|
||||
ariaLabel: 'Automatic supervision settings and history',
|
||||
overview: 'Run overview',
|
||||
suggestions: 'Pending suggestions',
|
||||
history: 'Reports and runs',
|
||||
history: 'Automatic reports and suggestions',
|
||||
plans: 'Automatic supervision'
|
||||
},
|
||||
currentStatus: {
|
||||
@@ -123,7 +175,7 @@ export const heartbeat = {
|
||||
},
|
||||
latest: {
|
||||
title: 'Latest review',
|
||||
viewHistory: 'View reports and runs',
|
||||
viewHistory: 'View report history',
|
||||
handleSuggestions: 'Review suggestions ({{formattedCount}})',
|
||||
empty:
|
||||
'There are no review reports yet. Run one to view insight, memory, and action suggestions.'
|
||||
@@ -146,10 +198,10 @@ export const heartbeat = {
|
||||
ignoreSuggestion: 'Ignore suggestion'
|
||||
},
|
||||
history: {
|
||||
timelineTitle: 'Review reports',
|
||||
timelineTitle: 'Automatic supervision reports',
|
||||
reportCount: 'Reports: {{formattedCount}}',
|
||||
emptyTimeline:
|
||||
'Reports from completed reviews appear here.',
|
||||
'No automatic supervision reports yet. Reports appear here after a plan runs.',
|
||||
reportSummary:
|
||||
'{{insights}} insights · {{memories}} memories · {{actions}} actions',
|
||||
collapseReport: 'Collapse report',
|
||||
@@ -191,10 +243,17 @@ export const heartbeat = {
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
refreshPlans: 'Refresh automatic supervision',
|
||||
refreshReports: 'Refresh reports and suggestions',
|
||||
executionHistory: 'Execution history',
|
||||
discardTitle: 'Discard unsaved plan changes?',
|
||||
discardHint: 'Closing will lose your unsaved changes.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
title: 'Automatic supervision',
|
||||
description:
|
||||
'Review the selected scope on schedule in read-only mode without tools. You confirm and handle suggestions.',
|
||||
scheduleHelp: 'Review daily or weekly at a specified time. Minute intervals are not supported. Automatic runs start only after you save and enable a plan.',
|
||||
scheduleHelp: 'Choose a daily or weekly review time, then save and enable the plan to run automatically.',
|
||||
timezone: 'Plan time zone: {{timezone}}. New plans use this device\'s time zone; edits keep the saved time zone.',
|
||||
windowSummary: 'Review the last {{hours}} hours · Keep run history for {{days}} days',
|
||||
allPaused: 'All automatic supervision plans are paused. Only manual reviews are available.',
|
||||
@@ -208,6 +267,7 @@ export const heartbeat = {
|
||||
timeLabel: 'Time',
|
||||
nameLabel: 'Plan name',
|
||||
createTitle: 'Create automatic supervision plan',
|
||||
close: 'Close automatic supervision plan',
|
||||
editTitle: 'Edit automatic supervision plan',
|
||||
cancelEdit: 'Cancel editing',
|
||||
editAriaLabel: 'Edit {{name}}',
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
export const heartbeat = {
|
||||
reviewSettings: {
|
||||
title: '回顾算法',
|
||||
responseKiB: '单次响应容量,KiB(默认 1024)',
|
||||
save: '保存回顾算法',
|
||||
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
|
||||
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
|
||||
batchMessages: '每批消息数(默认 20)', executionSeconds: '单次整理时长,秒(默认 300)',
|
||||
pauseHelp: '执行时间达到软预算后,正在处理的批次完成并保存,再暂停;可从活动记录继续。',
|
||||
invalid: '请输入所示范围内的整数。',
|
||||
pause: '暂停回顾', resume: '继续已保存回顾', facts: '已保留事实与来源',
|
||||
pausedHint: '本次回顾尚未完成,成功批次已保存。请到活动页查看进度并继续。',
|
||||
restartRequired: '冻结来源已修改或移除,已保存事实仍可读取。请从工作回顾新建回顾;下次自动检查也会使用新运行。',
|
||||
factsHelp: '摘要用于导航,可展开已保存批次查看详细记录与原文。请对照来源核对模型整理的内容。',
|
||||
revision: '来源版本', sources: '全部已覆盖来源片段',
|
||||
previousPage: '上一页', nextPage: '下一页',
|
||||
extracting: '正在整理来源批次', summarizing: '正在生成导航摘要', inFlight: '{{count}} 个模型任务排队或执行中',
|
||||
configuration: '本次回顾的已保存配置',
|
||||
configurationValues: '每次读取 {{pageSize}} 个来源;每批文本量 {{batchCharacters}},最多 {{batchMessages}} 条消息;整理 {{executionSeconds}} 秒后暂停;模型超时 {{timeoutSeconds}} 秒;最多同时处理 {{concurrency}} 个任务。实际并发还受当前监督模型并发设置限制。',
|
||||
progress: '已保存 {{batches}} 批 · 已读 {{characters}} 个字符 · 剩余 {{remaining}} 个来源',
|
||||
identityError: '模型返回的实体引用无效,本批未保存,之前成功的批次仍保留。',
|
||||
runError: '本次运行失败,请查看错误详情后继续。已保存的批次仍保留。',
|
||||
diagnostics: '错误详情'
|
||||
},
|
||||
timeouts: {
|
||||
title: '模型超时与并发',
|
||||
report: '心跳报告超时(秒)',
|
||||
organize: '监督者整理超时(秒)',
|
||||
help: '两个模型超时默认均为 240 秒,可分别设置为 30 至 600 的整数秒。报告在发起时固定超时;回顾在创建时保存整理超时,继续时沿用。',
|
||||
transport: '仅限制模型执行阶段,不包含数据收集和保存。直连模型传输另有独立的 600 秒超时,此处设置不会延长该限制;服务商或 Runtime 的限制也可能使请求提前结束。',
|
||||
concurrency: '监督模型并发数',
|
||||
concurrencyHelp: '心跳报告与监督者整理共用此上限,默认 1,可设为 1 至 4。排队时间不计入模型超时;降低上限只影响后续调度,不中断在途请求。仅限制监督请求,普通聊天不受此设置限制,但仍与监督共用上游模型额度。',
|
||||
invalid: '请为两个超时分别输入 30 至 600 的整数秒,并发数请输入 1 至 4 的整数。',
|
||||
save: '保存模型设置'
|
||||
},
|
||||
activity: {
|
||||
title: '活动', description: '记录手动回顾和自动监督的实际执行,停留在此页签时自动刷新。实体与关系修改目前没有按时间保存的操作审计。',
|
||||
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在设置中配置自动监督。',
|
||||
stages: '回顾阶段',
|
||||
phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' },
|
||||
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
|
||||
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
|
||||
navigationSaved: '已保存 {{count}} 次导航合并',
|
||||
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
|
||||
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
|
||||
noSavedBatches: '尚无已保存批次。', unassigned: '未绑定项目', projectSources: '项目来源',
|
||||
pageBatches: '本页 {{count}} 批', batchSaved: '已保存',
|
||||
refreshOverview: '刷新运行概览',
|
||||
planFilter: '监督计划',
|
||||
allPlans: '全部计划与手动回顾',
|
||||
unavailablePlan: '计划不可用',
|
||||
clearFilter: '清除筛选',
|
||||
filteredEmpty: '此计划暂无执行记录',
|
||||
filteredEmptyHint: '可以返回自动监督手动运行,或清除筛选查看其他活动。',
|
||||
title: '活动记录', description: '记录手动回顾和自动监督的实际执行,停留在此页签时自动刷新。',
|
||||
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在自动监督中创建计划。',
|
||||
kind: { supervision: '工作回顾', heartbeat: '自动监督' },
|
||||
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)' },
|
||||
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续' },
|
||||
triggers: { manual: '用户触发', scheduled: '定时计划', heartbeat: '心跳触发' },
|
||||
trigger: '触发方式', started: '开始时间', finished: '结束时间', unknownScope: '未保存执行范围',
|
||||
heartbeatStage: '心跳报告', supervisionStage: '监督回顾', notRecorded: '无执行记录',
|
||||
@@ -15,17 +65,19 @@ export const heartbeat = {
|
||||
unknownTime: '时间未知'
|
||||
},
|
||||
supervisor: {
|
||||
automatic: '自动监督',
|
||||
projectScope: '项目:{{names}}',
|
||||
canvasTitle: '工作的来路', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
|
||||
visibleCounts: '画布中 {{events}} 个事件 / {{entities}} 个实体', showAll: '显示全部关联', connections: '图谱关联',
|
||||
loadingHint: '正在读取已保存的回顾、事件与来源。', unavailableHint: '请重新打开应用后重试;已保存的回顾仍保留在本机。',
|
||||
selection: '图谱选择', canvas: '故事图谱画布', inspector: '详情与来源',
|
||||
counts: '{{events}} 个事件 · {{entities}} 个实体', canvasCaption: '事件按时间顺序等距排列,不表示时间间隔。画布每批最多显示 8 个事件和 6 个实体;从列表选择可切换批次并查看完整名称,窄屏可横向滚动画布。当前实体状态不随事件浏览还原。',
|
||||
counts: '{{events}} 个事件 · {{entities}} 个实体', canvasCaption: '事件按时间顺序等距排列,不表示时间间隔。画布每批最多显示 8 个事件和 6 个实体;从列表选择可切换批次并查看完整名称,窄屏可横向滚动画布。实体状态以所选回顾结果为准。',
|
||||
legendLabel: '图谱图例', eventImpact: '事件影响', playback: '事件浏览', previousStage: '上一事件', nextStage: '下一事件',
|
||||
navigation: '监督者视图', recap: '工作回顾', graph: '故事线图谱', settings: '设置',
|
||||
viewInGraph: '在图谱中查看',
|
||||
unavailable: '监督者服务暂不可用', loading: '正在读取监督回顾', scope: '关注范围', period: '时间范围', days: '{{count}} 天',
|
||||
sourcesHint: '手动回顾会重新整理所选区间,包括已回顾的来源;自动监督只处理新增或修改的来源片段。删除报告不会重置自动处理进度,也不会重建图谱。', latest: '最近一次成功回顾', openItems: '未解决事项', history: '历史结果',
|
||||
sourcesHint: '选择范围和时间发起新回顾;下方显示所选结果及其覆盖区间。', latest: '监督结果', openItems: '未解决事项', history: '历史结果',
|
||||
newReview: '新回顾', summary: '工作总结', changes: '本次变化', runningHint: '新回顾正在整理,已有结果仍可阅读。',
|
||||
empty: '还没有成功回顾', emptyHint: '即使没有自动监督计划,也可以手动回顾当前进展。', run: '回顾当前进展', running: '回顾整理中…', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
|
||||
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
|
||||
@@ -58,7 +110,7 @@ export const heartbeat = {
|
||||
ariaLabel: '自动监督设置与记录',
|
||||
overview: '运行概览',
|
||||
suggestions: '待处理建议',
|
||||
history: '报告与记录',
|
||||
history: '自动监督报告与建议',
|
||||
plans: '自动监督'
|
||||
},
|
||||
currentStatus: {
|
||||
@@ -119,7 +171,7 @@ export const heartbeat = {
|
||||
},
|
||||
latest: {
|
||||
title: '最近回顾',
|
||||
viewHistory: '查看报告与记录',
|
||||
viewHistory: '查看历史报告',
|
||||
handleSuggestions: '处理 {{formattedCount}} 条建议',
|
||||
empty:
|
||||
'尚无回顾报告。运行一次后,可在这里查看洞察、记忆和行动建议。'
|
||||
@@ -142,9 +194,9 @@ export const heartbeat = {
|
||||
ignoreSuggestion: '忽略建议'
|
||||
},
|
||||
history: {
|
||||
timelineTitle: '回顾报告',
|
||||
timelineTitle: '自动监督报告',
|
||||
reportCount: '{{formattedCount}} 份报告',
|
||||
emptyTimeline: '每次运行生成的回顾报告会显示在这里。',
|
||||
emptyTimeline: '暂无自动监督报告。计划运行后,报告会显示在这里。',
|
||||
reportSummary:
|
||||
'{{insights}} 条洞察 · {{memories}} 条记忆 · {{actions}} 个行动',
|
||||
collapseReport: '收起报告',
|
||||
@@ -186,9 +238,16 @@ export const heartbeat = {
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
refreshPlans: '刷新自动监督',
|
||||
refreshReports: '刷新报告与建议',
|
||||
executionHistory: '执行记录',
|
||||
discardTitle: '放弃未保存的计划修改?',
|
||||
discardHint: '关闭后,本次未保存的修改将丢失。',
|
||||
keepEditing: '继续编辑',
|
||||
discard: '放弃修改',
|
||||
title: '自动监督',
|
||||
description: '按计划只读回顾所选范围,不调用工具;建议由你确认和处理。',
|
||||
scheduleHelp: '支持每天或每周在指定时间回顾,暂不支持按分钟间隔运行。填写后需保存并启用计划才会自动运行。',
|
||||
scheduleHelp: '选择每天或每周的回顾时间,保存并启用计划后自动运行。',
|
||||
timezone: '计划时区:{{timezone}}。新计划使用本机时区,编辑时保留原时区。',
|
||||
windowSummary: '回顾最近 {{hours}} 小时 · 运行历史保留 {{days}} 天',
|
||||
allPaused: '所有自动监督计划已暂停,目前仅支持手动回顾。',
|
||||
@@ -202,6 +261,7 @@ export const heartbeat = {
|
||||
timeLabel: '时间',
|
||||
nameLabel: '计划名称',
|
||||
createTitle: '创建自动监督计划',
|
||||
close: '关闭自动监督计划',
|
||||
editTitle: '编辑自动监督计划',
|
||||
cancelEdit: '取消编辑',
|
||||
editAriaLabel: '编辑 {{name}}',
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
min-width: 0;
|
||||
container: supervisor / inline-size;
|
||||
}
|
||||
.supervisor-workspace[data-view="overview"] {
|
||||
width: min(100%, var(--content-reading));
|
||||
}
|
||||
.heartbeat-center > [role='tabpanel'] {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
@@ -60,17 +63,15 @@
|
||||
.supervisor-workspace__toolbar {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: var(--space-4) 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
margin: var(--space-3) 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.supervisor-workspace__toolbar label {
|
||||
flex: 1 1 180px;
|
||||
flex: 0 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
.supervisor-workspace__toolbar label,
|
||||
.supervisor-workspace__result-navigation label,
|
||||
.supervisor-workspace__recap label,
|
||||
.supervisor-workspace__detail label {
|
||||
display: grid;
|
||||
@@ -79,6 +80,7 @@
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.supervisor-workspace__toolbar select,
|
||||
.supervisor-workspace__result-navigation select,
|
||||
.supervisor-workspace__recap select,
|
||||
.supervisor-workspace__detail input {
|
||||
min-height: 32px;
|
||||
@@ -120,9 +122,35 @@
|
||||
}
|
||||
.supervisor-workspace__summary {
|
||||
font-size: var(--font-section-title);
|
||||
line-height: 1.6;
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.supervisor-workspace__recap > .supervisor-workspace__section-heading,
|
||||
.supervisor-workspace__recap > p,
|
||||
.supervisor-workspace__prose {
|
||||
width: 100%;
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
.supervisor-workspace__prose h3 {
|
||||
margin-top: var(--space-6);
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
.supervisor-workspace__result-navigation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.supervisor-workspace__result-navigation label {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
.supervisor-workspace__result-navigation select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.supervisor-workspace__recap li {
|
||||
margin: var(--space-2) 0;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -576,12 +604,13 @@
|
||||
}
|
||||
}
|
||||
@container supervisor (max-width: 420px) {
|
||||
.supervisor-workspace__toolbar > * {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
.supervisor-workspace__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.supervisor-workspace__toolbar button {
|
||||
width: 100%;
|
||||
.supervisor-workspace__toolbar > strong {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.supervisor-workspace__recap,
|
||||
.supervisor-workspace__detail,
|
||||
@@ -594,7 +623,13 @@
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
width: min(100%, var(--content-dashboard));
|
||||
}
|
||||
.supervisor-activity > .supervisor-workspace__action-bar {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.supervisor-activity > .supervisor-activity__note { margin: 0; }
|
||||
|
||||
.supervisor-activity__list {
|
||||
list-style: none;
|
||||
@@ -638,3 +673,73 @@
|
||||
.supervisor-activity__status--running { color: var(--accent); }
|
||||
.supervisor-activity__status--completed { color: var(--success); }
|
||||
.supervisor-activity__status--failed { color: var(--danger); }
|
||||
.supervisor-activity__item > header time { margin-left: auto; color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__scope { color: var(--text-secondary); margin-block: var(--space-2) var(--space-4); }
|
||||
.supervisor-activity__steps { display: flex; flex-wrap: wrap; gap: var(--space-4); padding: 0; margin: 0; list-style: none; }
|
||||
.supervisor-activity__steps li { display: flex; align-items: baseline; gap: var(--space-2); padding-block: var(--space-1); }
|
||||
.supervisor-activity__steps strong { font-size: var(--font-body); font-weight: 500; }
|
||||
.supervisor-activity__steps small { margin-left: var(--space-2); color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__step-number { color: var(--text-muted); }
|
||||
.supervisor-activity__steps [data-state="failed"] small { color: var(--danger); }
|
||||
.supervisor-activity__steps [data-state="running"] small { color: var(--accent); }
|
||||
.supervisor-activity__steps [data-state="completed"] small { color: var(--success); }
|
||||
.supervisor-activity__coverage { margin-bottom: var(--space-2); }
|
||||
.supervisor-activity__workflow > .supervisor-activity__note { margin-block: var(--space-2); }
|
||||
.supervisor-activity__failure { margin-top: var(--space-3); border-left: 2px solid var(--danger-border); padding-left: var(--space-3); }
|
||||
.supervisor-activity__failure > p { color: var(--text-secondary); }
|
||||
.supervisor-activity__diagnostics pre,
|
||||
.supervisor-activity__tree pre { max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--space-3); background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-control); user-select: text; }
|
||||
.supervisor-activity__item summary { cursor: pointer; padding-block: var(--space-2); }
|
||||
.supervisor-activity__item summary:focus-visible,
|
||||
.supervisor-activity__item pre:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.supervisor-activity__advanced { margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.supervisor-activity__tree { margin-top: var(--space-3); padding: var(--space-3); background: var(--surface-subtle); border-radius: var(--radius-control); min-width: 0; }
|
||||
.supervisor-activity__conversation,
|
||||
.supervisor-activity__batch { margin-left: var(--space-3); padding-left: var(--space-3); border-left: 1px solid var(--border-default); }
|
||||
.supervisor-activity__conversation > summary > span { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__batch > summary { display: list-item; overflow-wrap: anywhere; }
|
||||
.supervisor-activity__batch-label { display: inline-block; vertical-align: top; max-width: calc(100% - var(--space-4)); max-height: 3em; line-height: 1.5; overflow: hidden; }
|
||||
@media (max-width: 719px) {
|
||||
.supervisor-activity__steps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.supervisor-activity__steps small { display: block; margin-left: 0; margin-top: var(--space-1); }
|
||||
.supervisor-activity__item > header time { margin-left: 0; flex-basis: 100%; }
|
||||
.supervisor-activity__conversation, .supervisor-activity__batch { margin-left: var(--space-1); padding-left: var(--space-2); }
|
||||
}
|
||||
.supervisor-center__sections {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.supervisor-center__sections .heartbeat-center__panel {
|
||||
padding-bottom: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.supervisor-center__sections > .heartbeat-center__section-heading {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.supervisor-center__sections .heartbeat-settings__editor > h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
.custom-task-dialog__content.heartbeat-settings {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.custom-task-dialog__content.heartbeat-settings .heartbeat-settings__editor {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.supervisor-activity .heartbeat-settings__field {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user