diff --git a/docs/review/execution-timing-io-2026-10-04.md b/docs/review/execution-timing-io-2026-10-04.md index fc31c4c3..00d91b69 100644 --- a/docs/review/execution-timing-io-2026-10-04.md +++ b/docs/review/execution-timing-io-2026-10-04.md @@ -14,6 +14,13 @@ requests remain paused during reconnection. Startup preserves closed intervals, discards unclosed intervals, and marks them incomplete; old history is not backfilled by scanning messages or events. +An incomplete zero duration means no known timed interval, not a measured zero. +Both conversation totals and task cards display "unavailable" in this case. +Positive partial durations retain the incomplete warning; complete zero durations +still display zero. The upgrade regression verifies that a new completed reply +in an old conversation retains its accumulated milliseconds after reopening the +database. This presentation fix does not restore pre-upgrade historical totals. + Schema 59 stores timing rows and adds a covering index for conversation summaries. The old historical aggregation, its caches, dedicated reader/worker, worker build entries, and five-second statistics polling have been removed. diff --git a/src/main/assistant/assistant-execution-stats.test.ts b/src/main/assistant/assistant-execution-stats.test.ts index 3b05aee6..2b34f11f 100644 --- a/src/main/assistant/assistant-execution-stats.test.ts +++ b/src/main/assistant/assistant-execution-stats.test.ts @@ -240,6 +240,18 @@ describe('persisted running-state duration', () => { expect(raw().prepare('PRAGMA index_info(messages_summary_idx)').all().map(row => row.name)) .toEqual(['conversation_id', 'sequence', 'role', 'created_at']) expect(raw().prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + const replyId = task() + database.startExecutionTiming(replyId) + now += 7000 + database.updateTaskStatus(replyId, 'completed') + database.endExecutionTiming(replyId) + expect(stats()).toMatchObject({ durationMs: 7000, runningCount: 0, incomplete: true }) + database.close() + now += 86_400_000 + database.initialize(directory) + expect(stats()).toMatchObject({ durationMs: 7000, runningCount: 0, incomplete: true }) + expect(raw().prepare('SELECT duration_ms FROM execution_timing WHERE task_id = ?').get(replyId)) + .toEqual({ duration_ms: 7000 }) }) it('deletes timing with its conversation and notifies snapshot subscribers', async () => { diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 2dd33693..9692c960 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -454,6 +454,20 @@ describe('RightAssistantSidebar resizing', () => { expect(screen.getByText('Project task a').closest('button')).toHaveAttribute('aria-current', 'true') expect(screen.getByText('Project task b').closest('article')).not.toHaveTextContent('任务累计用时') + view.rerender(sidebarElement({ ...props, + conversationStats: { ...props.conversationStats, replyDurationMs: 0 }, + taskDurations: new Map([['a', { durationMs: 0, incomplete: true }]]) + })) + expect(stats.getByText('暂无统计')).toBeVisible() + expect(stats.queryByText('00:00:00')).not.toBeInTheDocument() + expect(screen.getByText('任务累计用时: 暂无统计 (部分记录)')).toBeVisible() + view.rerender(sidebarElement({ ...props, + conversationStats: { ...props.conversationStats, replyDurationMs: 500 }, + taskDurations: new Map([['a', { durationMs: 500, incomplete: true }]]) + })) + expect(stats.getByText('00:00:00')).toBeVisible() + expect(screen.getByText('任务累计用时: 00:00:00 (部分记录)')).toBeVisible() + view.rerender(sidebarElement({ ...props, activeConversationId: 'b' })) expect(stats.queryByText('Conversation A')).not.toBeInTheDocument() expect(stats.getAllByText('暂无统计')).toHaveLength(2) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 7eb066d2..585b1d8d 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -838,7 +838,8 @@ function RightAssistantSidebarView({ const currentStats = activeConversationId && conversationStats?.conversationId === activeConversationId ? conversationStats : undefined - const formatDuration = (durationMs: number): string => { + const formatDuration = (durationMs: number, incomplete = false): string => { + if (incomplete && durationMs === 0) return t('sidebar.tasks.stats.unavailable') const seconds = Math.floor(Math.max(0, durationMs) / 1000) return `${Math.floor(seconds / 3600).toString().padStart(2, '0')}:${Math.floor(seconds / 60 % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}` } @@ -2105,7 +2106,7 @@ function RightAssistantSidebarView({ ? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation') : t('sidebar.tasks.stats.noConversation')}
-
{t('sidebar.tasks.stats.replyDuration')}
{currentStats ? formatDuration(currentStats.replyDurationMs) : t('sidebar.tasks.stats.unavailable')}
+
{t('sidebar.tasks.stats.replyDuration')}
{currentStats ? formatDuration(currentStats.replyDurationMs, currentStats.incomplete) : t('sidebar.tasks.stats.unavailable')}
{t('sidebar.tasks.stats.messages')}
{currentStats ? currentStats.messageCount.toLocaleString(locale) : t('sidebar.tasks.stats.unavailable')}
{currentStats?.incomplete &&

{t('sidebar.tasks.stats.incomplete')}

} @@ -2253,7 +2254,7 @@ function RightAssistantSidebarView({ {t(`task.status.${task.status}`)} {duration && - {t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs)} + {t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs, duration.incomplete)} {duration.incomplete && ` (${t('sidebar.tasks.stats.partial')})`} }