From 126dbc57e23e46b1b0269a33350377f5ae227b5b Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 5 Oct 2026 02:49:02 +0800 Subject: [PATCH] feat: support custom supervisor review date ranges --- .../conversation-supervision/ui-design.md | 8 +- src/renderer/src/SupervisorWorkspace.test.tsx | 105 ++++++++++++++++++ src/renderer/src/SupervisorWorkspace.tsx | 79 +++++++++++-- .../src/i18n/locales/en-US/heartbeat.ts | 7 +- .../src/i18n/locales/zh-CN/heartbeat.ts | 7 +- src/renderer/src/supervisor-workspace.css | 25 +++++ 6 files changed, 216 insertions(+), 15 deletions(-) diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 40146bef..b040a80f 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -86,7 +86,13 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 2026-09-30 已实现(规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)):主按钮“回顾”执行增量整理,文案不再称“重新分析”;旁边“更多”菜单提供“重新整理…”,确认对话说明将重新分析所选区间并可能产生模型费用。计划 Modal 增加“介入方式”单选:“生成建议”“仅更新记忆”;主动介入上线前不显示。最近建议替代,每条建议显示依据、打开来源及转任务/确认/忽略。“最近建议”替代,每条建议显示依据、打开来源及转任务/确认/忽略。 -当前页面提供全局或单项目范围,以及 1、7、30 天时间区间选择。没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。 +当前页面提供全局或单项目范围。时间选择保留 1、7、30 天快捷项,默认 7 天,并提供“自定义天数”和“指定日期”。快捷项与自定义天数均从提交时刻向前计算,每天按 24 小时计;自定义值必须为正整数,且起始时间不得早于公元 1 年。 + +指定日期使用带可见标签的原生日期输入,按本地时区解释。起始时间为开始日期的零点,结束时间包含结束日期全天:取下一个本地日期的零点减 1 毫秒,跨夏令时不按固定 24 小时推算;结束日期为今天时截到提交时刻。允许同一天,拒绝缺失、无效、未来日期及开始晚于结束。天数和日期输入复用共享字段样式,窄窗口换行;错误就地显示,通过 `aria-invalid` 和 `aria-describedby` 关联输入,无效时禁用回顾和重新整理,提交入口再次校验。 + +重新整理确认显示当前范围及最近天数或明确起止日期,日期文案说明本地日期和今天截至当前的规则。修改范围或时间输入后关闭旧确认,重新整理仍需再次确认。新选择只改变请求时间范围,普通回顾继续跳过已处理且未变化的来源,重新整理仍使用既有显式标记,不改变增量处理规则。 + +没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。 选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。 diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index 9beec8a1..05c96097 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -15,6 +15,111 @@ const selectHistory = (id: string) => { } describe('SupervisorWorkspace', () => { + it.each(['zh-CN', 'en-US'] as const)('submits custom days and keeps reanalysis confirmation consistent in %s', async locale => { + await changeUiLocale(locale) + const copy = i18nResources[locale].heartbeat.supervisor + const run = vi.fn().mockResolvedValue(undefined) + window.goodbuddy = { supervision: { overview: async () => [], run } } as never + render() + await screen.findByText(copy.empty) + vi.useFakeTimers({ toFake: ['Date'] }) + const now = new Date(2026, 9, 5, 15, 30) + vi.setSystemTime(now) + fireEvent.click(screen.getByRole('button', { name: copy.newReview })) + const picker = screen.getByLabelText(copy.period) + expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range']) + expect(picker).toHaveValue('7') + fireEvent.change(picker, { target: { value: 'custom' } }) + fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run }))) + const timeRange = { from: new Date(now.getTime() - 45 * 86400_000).toISOString(), to: now.toISOString() } + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange }) + fireEvent.click(screen.getByRole('button', { name: copy.more })) + fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze })) + expect(screen.getByRole('alertdialog')).toHaveTextContent(copy.recentDays.replace('{{count}}', '45')) + fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '46' } }) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: copy.more })) + fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze })) + expect(screen.getByRole('alertdialog')).toHaveTextContent(copy.recentDays.replace('{{count}}', '46')) + await act(async () => fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: copy.reanalyzeConfirm }))) + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, + timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } }) + }) + + it.each([ + ['2025-11-02', '2025-11-02'], + ['2026-03-08', '2026-03-08'], + ['2026-09-29', '2026-10-02'], + ['2026-10-05', '2026-10-05'] + ])('includes local calendar days %s through %s for review and reanalysis', async (start, end) => { + const copy = i18nResources['zh-CN'].heartbeat.supervisor + const run = vi.fn().mockResolvedValue(undefined) + window.goodbuddy = { supervision: { overview: async () => [], run } } as never + render() + await screen.findByText(copy.empty) + vi.useFakeTimers({ toFake: ['Date'] }) + const now = new Date(2026, 9, 5, 15, 30) + vi.setSystemTime(now) + fireEvent.click(screen.getByRole('button', { name: copy.newReview })) + fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'range' } }) + fireEvent.change(screen.getByLabelText(copy.startDate), { target: { value: start } }) + fireEvent.change(screen.getByLabelText(copy.endDate), { target: { value: end } }) + const timeRange = { from: new Date(`${start}T00:00:00`).toISOString(), + to: end === '2026-10-05' ? now.toISOString() : new Date(`${end}T23:59:59.999`).toISOString() } + await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run }))) + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange }) + fireEvent.click(screen.getByRole('button', { name: copy.more })) + fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze })) + const dialog = screen.getByRole('alertdialog') + expect(dialog).toHaveTextContent(copy.dateRangeLabel.replace('{{from}}', start).replace('{{to}}', end)) + await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm }))) + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange }) + }) + + it('blocks invalid days and dates before execution lookup, preserves drafts and recovers', async () => { + const copy = i18nResources['zh-CN'].heartbeat.supervisor + const run = vi.fn().mockResolvedValue(undefined) + const execution = vi.fn().mockResolvedValue({ active: false }) + window.goodbuddy = { supervision: { overview: async () => [], run, execution } } as never + render() + await screen.findByText(copy.empty) + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(2026, 9, 5, 15, 30)) + fireEvent.click(screen.getByRole('button', { name: copy.newReview })) + const picker = screen.getByLabelText(copy.period) + fireEvent.change(picker, { target: { value: 'custom' } }) + const days = screen.getByRole('spinbutton', { name: copy.dayCount }) + for (const value of ['', '0', '-1', '1.5', '1e3', '999999999999999999999', '1000000']) { + fireEvent.change(days, { target: { value } }) + expect(days).toHaveAttribute('aria-invalid', 'true') + expect(days).toHaveAccessibleDescription(copy.invalidDays) + expect(screen.getByRole('button', { name: copy.run })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: copy.run })) + fireEvent.click(screen.getByRole('button', { name: copy.more })) + expect(screen.getByRole('menuitem', { name: copy.reanalyze })).toBeDisabled() + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + } + fireEvent.change(days, { target: { value: '42' } }) + fireEvent.change(picker, { target: { value: 'range' } }) + for (const [start, end, error] of [ + ['', '', copy.invalidDates], ['2026-02-30', '2026-03-02', copy.invalidDates], + ['2026-10-03', '', copy.invalidDates], ['2026-10-04', '2026-10-03', copy.reversedDates], + ['2026-10-04', '2026-10-06', copy.futureDates], ['2026-10-06', '2026-10-06', copy.futureDates] + ]) { + fireEvent.change(screen.getByLabelText(copy.startDate), { target: { value: start } }) + fireEvent.change(screen.getByLabelText(copy.endDate), { target: { value: end } }) + expect(screen.getByLabelText(copy.endDate)).toHaveAccessibleDescription(error) + expect(screen.getByRole('button', { name: copy.run })).toBeDisabled() + } + expect(execution).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + fireEvent.change(picker, { target: { value: 'custom' } }) + expect(screen.getByRole('spinbutton', { name: copy.dayCount })).toHaveValue(42) + await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run }))) + expect(run).toHaveBeenCalledOnce() + }) + it.each(['overview', 'graph'] as const)('shows each history result scope and range only inside the %s picker', async tab => { const old = { ...result, id: 'old', createdAt: '2026-08-02T00:00:00.000Z', scope: { kind: 'projects', projectIds: ['11111111-1111-4111-8111-111111111111'] }, diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index f719355f..2a215ec0 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -95,7 +95,10 @@ export function SupervisorWorkspace({ conversationId?: string }>() const [projectId, setProjectId] = useState('global') - const [days, setDays] = useState(7) + const [period, setPeriod] = useState('7') + const [customDays, setCustomDays] = useState('7') + const [startDate, setStartDate] = useState('') + const [endDate, setEndDate] = useState('') const [newReviewOpen, setNewReviewOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(Boolean(graphNavigation?.focus)) const detailToggleRef = useRef(null) @@ -215,9 +218,40 @@ export function SupervisorWorkspace({ return () => { window.clearTimeout(task); invalidate() } }, [refresh, graphNavigation]) + const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => { + if (period === 'range') { + const from = new Date(`${startDate}T00:00:00`) + const end = new Date(`${endDate}T00:00:00`) + const validDate = (value: string, parsed: Date) => /^\d{4}-\d{2}-\d{2}$/.test(value) + && Number(value.slice(0, 4)) >= 1 && Number.isFinite(parsed.getTime()) + && parsed.getFullYear() === Number(value.slice(0, 4)) + && parsed.getMonth() + 1 === Number(value.slice(5, 7)) && parsed.getDate() === Number(value.slice(8, 10)) + if (!validDate(startDate, from) || !validDate(endDate, end)) return { error: t('supervisor.invalidDates') } + if (from > now || end > now) return { error: t('supervisor.futureDates') } + if (from > end) return { error: t('supervisor.reversedDates') } + // The backend includes both endpoints. Advance by a local calendar day for DST. + const to = new Date(end) + to.setDate(to.getDate() + 1) + to.setHours(0, 0, 0, -1) + return { range: { from: from.toISOString(), to: new Date(Math.min(to.getTime(), now.getTime())).toISOString() }, + label: t('supervisor.dateRangeLabel', { from: startDate, to: endDate }) } + } + const value = period === 'custom' ? customDays : period + const days = Number(value) + const from = new Date(now.getTime() - days * 86400_000) + if (!/^\d+$/.test(value) || !Number.isSafeInteger(days) || days < 1 + || !Number.isFinite(from.getTime()) || from.getFullYear() < 1) return { error: t('supervisor.invalidDays') } + return { range: { from: from.toISOString(), to: now.toISOString() }, label: t('supervisor.recentDays', { count: days }) } + } + const reviewPeriod = resolvePeriod(new Date()) + const today = new Date() + const maxDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + // A review is incremental by default. Only an explicit re-analysis reads the whole interval again. const run = async (reanalyze = false) => { if (!api) return + const { range } = resolvePeriod(new Date()) + if (!range) return setConfirmReanalyze(false) const notice = (message: string, tone: AppNotificationInput['tone'] = 'info') => { if (mounted.current) onNotify?.({ tone, message, dedupeKey: 'supervisor-review' }) @@ -228,7 +262,6 @@ export function SupervisorWorkspace({ } runPending.current = true const generation = loadGeneration.current - const to = new Date() const request: SupervisionRunRequest = { trigger: 'manual', ...(reanalyze ? { reanalyze: true } : {}), @@ -236,10 +269,7 @@ export function SupervisorWorkspace({ projectId === 'global' ? { kind: 'global' } : { kind: 'projects', projectIds: [projectId] }, - timeRange: { - from: new Date(to.getTime() - days * 86400_000).toISOString(), - to: to.toISOString() - } + timeRange: range } let submitted = false try { @@ -578,7 +608,7 @@ export function SupervisorWorkspace({ setDays(Number(event.target.value))} + value={period} + onChange={(event) => { setPeriod(event.target.value); setConfirmReanalyze(false) }} > {[1, 7, 30].map((value) => ( ))} + + + {period === 'custom' && } + {period === 'range' &&
+ + +
} {moreOpen && setMoreOpen(false)}> - } + {(period === 'custom' || period === 'range') &&

+ {reviewPeriod.error ?? t(period === 'range' ? 'supervisor.dateRangeHelp' : 'supervisor.customDaysHelp')} +

} {confirmReanalyze &&
{ if (event.key === 'Escape') setConfirmReanalyze(false) }}> {t('supervisor.reanalyzeTitle')}

{t('supervisor.reanalyzeHint', { scope: projectId === 'global' ? t('center.scope.global') - : projects.find(project => project.id === projectId)?.name ?? '', period: t('supervisor.days', { count: days }) })}

+ : projects.find(project => project.id === projectId)?.name ?? '', period: reviewPeriod.label })}

- +
} diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index 98bed983..df5e2b01 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -120,7 +120,12 @@ export const heartbeat = { 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 heartbeat plan.', run: 'Review', running: 'Reviewing…', more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze', - reanalyzeHint: 'All content in {{scope}} from the last {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss', + reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss', + customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date', + recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)', + customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.', + invalidDays: 'Enter a positive whole number of days that produces a date in year 1 or later.', + invalidDates: 'Enter valid start and end dates.', futureDates: 'Dates cannot be in the future.', reversedDates: 'Start date must be on or before end date.', graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End', listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations', story: 'Stories', experience: 'Experiences' }, experiences: { diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index 3e7de5dc..7098ec71 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -117,7 +117,12 @@ export const heartbeat = { newReview: '新回顾', summary: '工作总结', changes: '本次变化', runningHint: '新回顾正在整理,已有结果仍可阅读。', empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…', more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理', - reanalyzeHint: '将重新分析{{scope}}最近 {{period}}的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示', + reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示', + customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期', + recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)', + customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。', + invalidDays: '请输入正整数天数,且起始时间不能早于公元 1 年。', + invalidDates: '请输入有效的开始和结束日期。', futureDates: '日期不能晚于今天。', reversedDates: '开始日期不能晚于结束日期。', graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点', listTabs: { event: '事件', entity: '实体', relation: '关系', story: '故事', experience: '经验' }, experiences: { diff --git a/src/renderer/src/supervisor-workspace.css b/src/renderer/src/supervisor-workspace.css index 32f3eb94..892c8e39 100644 --- a/src/renderer/src/supervisor-workspace.css +++ b/src/renderer/src/supervisor-workspace.css @@ -362,6 +362,31 @@ flex: 1; min-width: 0; } +.supervisor-workspace__date-range { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + max-width: 100%; + min-width: 0; + grid-column: 1 / -1; +} +.supervisor-workspace__toolbar .field-control { + width: 100%; + min-width: 0; + box-sizing: border-box; +} +.supervisor-workspace__period-field { + grid-column: 1 / -1; +} +.supervisor-workspace__period-help { + flex-basis: 100%; + grid-column: 1 / -1; + margin: 0; + color: var(--text-secondary); +} +.supervisor-workspace__period-help[role='alert'] { + color: var(--danger); +} .supervisor-workspace__history-menu [role="menuitemradio"] > span { flex: 1; min-width: 0;