diff --git a/src/renderer/src/components/automations/details/AutomationDetail.fixture.tsx b/src/renderer/src/components/automations/details/AutomationDetail.fixture.tsx index 82234c99197..e6195c622c7 100644 --- a/src/renderer/src/components/automations/details/AutomationDetail.fixture.tsx +++ b/src/renderer/src/components/automations/details/AutomationDetail.fixture.tsx @@ -90,6 +90,27 @@ export const failed = defineFixture({ }) }) +export const foldedRun = defineFixture({ + width: 1000, + render: () => + renderDetail({ + automation: { ...automation, lastRunAt: NOW - 6 * HOUR }, + runs: [ + makeRun({ + id: 'run-4', + status: 'skipped_unavailable', + error: 'The execution host is offline.', + scheduledFor: NOW - 8 * 24 * HOUR - 6 * HOUR, + createdAt: NOW - 8 * 24 * HOUR - 6 * HOUR, + dispatchedAt: null, + startedAt: null, + occurrenceCount: 9, + lastOccurrenceAt: NOW - 6 * HOUR + }) + ] + }) +}) + export const paused = defineFixture({ width: 720, render: () => diff --git a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx index 585d10403df..1229de1b5f1 100644 --- a/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx +++ b/src/renderer/src/components/automations/details/AutomationDetailHealth.tsx @@ -107,11 +107,12 @@ export function AutomationDetailHealth({ const usageDetail = hasUsage ? t( 'auto.components.automations.AutomationDetail.usageDetail', - '{{tokens}} tokens · {{known}}/{{total}} runs reported', + '{{tokens}} tokens · {{known}}/{{count}} runs reported', { tokens: formatAutomationTokens(usage.totalTokens, intlLocale), known: usage.knownRuns, - total: runs.length + count: runs.length, + defaultValue_one: '{{tokens}} tokens · {{known}}/{{count}} run reported' } ) : null diff --git a/src/renderer/src/components/automations/listing/automation-list-last-run.test.ts b/src/renderer/src/components/automations/listing/automation-list-last-run.test.ts new file mode 100644 index 00000000000..70cee785d21 --- /dev/null +++ b/src/renderer/src/components/automations/listing/automation-list-last-run.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { summarizeAutomationRunUsage } from '../../../../../shared/automations/history/automation-usage-summary' +import { makeAutomation, makeAutomationListRow, makeRun } from '../automations-page-fixtures' +import { + getAutomationRowLastRunSnapshot, + getLocalAutomationLastRunSnapshot +} from './automation-list-last-run' + +const FIRST_OCCURRENCE = Date.UTC(2026, 10, 1, 1, 0) +const LATEST_OCCURRENCE = Date.UTC(2026, 10, 9, 1, 0) + +const automation = makeAutomation({ lastRunAt: LATEST_OCCURRENCE }) +const foldedRun = makeRun({ + status: 'skipped_unavailable', + error: 'Execution host is offline.', + scheduledFor: FIRST_OCCURRENCE, + createdAt: FIRST_OCCURRENCE, + startedAt: null, + dispatchedAt: null, + occurrenceCount: 9, + lastOccurrenceAt: LATEST_OCCURRENCE +}) + +describe('last run time of a folded skipped run', () => { + it('uses the latest occurrence when the run history is loaded', () => { + expect(getLocalAutomationLastRunSnapshot(automation, foldedRun).at).toBe(LATEST_OCCURRENCE) + }) + + it('uses the latest occurrence from the projected list summary', () => { + const row = makeAutomationListRow({ + automation, + usageSummary: summarizeAutomationRunUsage([foldedRun]) + }) + expect(getAutomationRowLastRunSnapshot(row).at).toBe(LATEST_OCCURRENCE) + }) + + it('keeps the dispatch time of a run that never folded', () => { + const run = makeRun({ createdAt: FIRST_OCCURRENCE, dispatchedAt: FIRST_OCCURRENCE + 5_000 }) + expect(getLocalAutomationLastRunSnapshot(automation, run).at).toBe(FIRST_OCCURRENCE + 5_000) + }) +}) diff --git a/src/renderer/src/components/automations/listing/automation-list-last-run.ts b/src/renderer/src/components/automations/listing/automation-list-last-run.ts index 717a1765cf0..0f8094ec285 100644 --- a/src/renderer/src/components/automations/listing/automation-list-last-run.ts +++ b/src/renderer/src/components/automations/listing/automation-list-last-run.ts @@ -1,9 +1,10 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import type { Automation } from '../../../../../shared/automations/automation-definition' -import type { - AutomationRun, - AutomationRunStatus +import { + getAutomationRunLastRunAt, + type AutomationRun, + type AutomationRunStatus } from '../../../../../shared/automations/history/automation-run' import type { ExternalAutomationJob } from '../../../../../shared/automations/external/external-automation' import { @@ -49,10 +50,6 @@ export function indexLatestAutomationRuns( return latest } -export function getAutomationRunLastRunAt(run: AutomationRun): number { - return run.dispatchedAt ?? run.startedAt ?? run.createdAt -} - export function getToneForAutomationRunStatus(status: AutomationRunStatus): AutomationLastRunTone { if (status === 'dispatch_failed') { return 'failed' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2232e4611f7..1ddc84b29ec 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -150,7 +150,9 @@ "graceHours_one": "{{count}} hour", "graceHours_other": "{{count}} hours", "graceMinutes_one": "{{count}} minute", - "graceMinutes_other": "{{count}} minutes" + "graceMinutes_other": "{{count}} minutes", + "usageDetail_one": "{{tokens}} tokens · {{known}}/{{count}} run reported", + "usageDetail_other": "{{tokens}} tokens · {{known}}/{{count}} runs reported" }, "AutomationRunsDashboard": { "historyUnavailable_one": "Run history is unavailable for {{count}} automation. Counts include available history only.", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 9e7c8ce85c3..24704f206af 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -17575,7 +17575,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Weniger anzeigen", - "showMore": "Mehr anzeigen" + "showMore": "Mehr anzeigen", + "copied": "Kopiert", + "copyFailed": "Kopieren fehlgeschlagen", + "copyPrompt": "Prompt kopieren" }, "AutomationDetail": { "host": "Host", @@ -17612,7 +17615,13 @@ "precheckNone": "Keine", "migrate": "Migrieren", "migrateNotice": "{{host}} wurde auf einen neuen Remote-Host umgestellt. Migriere diese Automatisierung, damit sie weiterläuft.", - "needsMigration": "Migration erforderlich" + "needsMigration": "Migration erforderlich", + "repeats": "Wiederholung", + "missedRunGrace": "Nachholfrist", + "execution": "Ausführung", + "notRunYet": "Noch nicht ausgeführt", + "usageDetail_one": "{{tokens}} Token · {{known}}/{{count}} Ausführung gemeldet", + "usageDetail_other": "{{tokens}} Token · {{known}}/{{count}} Ausführungen gemeldet" }, "AutomationEditorDialog": { "fb1896a5e7": "Abbrechen", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 24feb204ba0..74152f8751c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18556,7 +18556,8 @@ "missedRunGrace": "Missed-run grace", "execution": "Execution", "notRunYet": "Not run yet", - "usageDetail": "{{tokens}} tokens · {{known}}/{{total}} runs reported", + "usageDetail_one": "{{tokens}} tokens · {{known}}/{{count}} run reported", + "usageDetail_other": "{{tokens}} tokens · {{known}}/{{count}} runs reported", "createdBy": "Created by", "createdByPluginTitle": "Plugin {{plugin}}", "createdByEnded": "Plugin removed or disabled; it must ask before changing this automation" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3e9cce1e71b..d28a1760d84 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17308,7 +17308,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Mostrar menos", - "showMore": "Mostrar más" + "showMore": "Mostrar más", + "copied": "Copiado", + "copyFailed": "No se pudo copiar", + "copyPrompt": "Copiar prompt" }, "AutomationDetail": { "host": "Host", @@ -17347,7 +17350,14 @@ "precheckNone": "Ninguna", "migrate": "Migrar", "migrateNotice": "{{host}} se actualizó a un nuevo host remoto. Migra esta automatización para que siga ejecutándose.", - "needsMigration": "Requiere migración" + "needsMigration": "Requiere migración", + "repeats": "Se repite", + "missedRunGrace": "Período de gracia", + "execution": "Ejecución", + "notRunYet": "Aún no se ha ejecutado", + "usageDetail_one": "{{tokens}} tokens · {{known}}/{{count}} ejecución informada", + "usageDetail_many": "{{tokens}} tokens · {{known}}/{{count}} ejecuciones informadas", + "usageDetail_other": "{{tokens}} tokens · {{known}}/{{count}} ejecuciones informadas" }, "AutomationEditorDialog": { "fb1896a5e7": "Cancelar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0b53f73d3ec..849f3ba5e61 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17549,7 +17549,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Afficher moins", - "showMore": "Afficher plus" + "showMore": "Afficher plus", + "copied": "Copié", + "copyFailed": "Échec de la copie", + "copyPrompt": "Copier le prompt" }, "AutomationDetail": { "401f40ae79": "Dépense est.", @@ -17588,7 +17591,14 @@ "precheckNone": "Aucune", "migrate": "Migrer", "migrateNotice": "{{host}} a été mis à niveau vers un nouvel hôte distant. Migrez cette automatisation pour qu'elle continue de s'exécuter.", - "needsMigration": "Migration requise" + "needsMigration": "Migration requise", + "repeats": "Répétition", + "missedRunGrace": "Marge de rattrapage", + "execution": "Exécution", + "notRunYet": "Pas encore exécutée", + "usageDetail_one": "{{tokens}} tokens · {{known}}/{{count}} exécution signalée", + "usageDetail_many": "{{tokens}} tokens · {{known}}/{{count}} exécutions signalées", + "usageDetail_other": "{{tokens}} tokens · {{known}}/{{count}} exécutions signalées" }, "AutomationEditorDialog": { "fb1896a5e7": "Annuler", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index de065cc070b..fecaa192c6e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16901,7 +16901,10 @@ }, "AutomationPromptDisclosure": { "showLess": "折りたたむ", - "showMore": "さらに表示" + "showMore": "さらに表示", + "copied": "コピーしました", + "copyFailed": "コピーに失敗しました", + "copyPrompt": "プロンプトをコピー" }, "AutomationDetail": { "host": "ホスト", @@ -16936,7 +16939,12 @@ "precheckNone": "なし", "migrate": "移行", "migrateNotice": "{{host}} は新しいリモートホストにアップグレードされました。実行を続けるには、この自動化を移行してください。", - "needsMigration": "移行が必要" + "needsMigration": "移行が必要", + "repeats": "繰り返し", + "missedRunGrace": "実行漏れの猶予時間", + "execution": "実行", + "notRunYet": "未実行", + "usageDetail_other": "{{tokens}} トークン · {{known}}/{{count}} 回の実行を報告済み" }, "AutomationEditorDialog": { "fb1896a5e7": "キャンセル", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b9f8f8cbdc2..07456d96b4d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16898,7 +16898,10 @@ }, "AutomationPromptDisclosure": { "showLess": "접기", - "showMore": "더 보기" + "showMore": "더 보기", + "copied": "복사됨", + "copyFailed": "복사하지 못함", + "copyPrompt": "프롬프트 복사" }, "AutomationDetail": { "host": "호스트", @@ -16933,7 +16936,12 @@ "precheckNone": "없음", "migrate": "마이그레이션", "migrateNotice": "{{host}}이(가) 새 원격 호스트로 업그레이드되었습니다. 계속 실행하려면 이 자동화를 마이그레이션하세요.", - "needsMigration": "마이그레이션 필요" + "needsMigration": "마이그레이션 필요", + "repeats": "반복", + "missedRunGrace": "누락된 실행 유예 시간", + "execution": "실행", + "notRunYet": "아직 실행되지 않음", + "usageDetail_other": "토큰 {{tokens}} · 실행 {{known}}/{{count}}회 보고됨" }, "AutomationEditorDialog": { "fb1896a5e7": "취소", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 4b64607e12e..0e0ed11ce24 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -14793,7 +14793,14 @@ "precheckNone": "Nenhuma", "migrate": "Migrar", "migrateNotice": "{{host}} foi atualizado para um novo host remoto. Migre esta automação para que ela continue em execução.", - "needsMigration": "Requer migração" + "needsMigration": "Requer migração", + "repeats": "Repetição", + "missedRunGrace": "Tolerância de execução perdida", + "execution": "Execução", + "notRunYet": "Ainda não executada", + "usageDetail_one": "{{tokens}} tokens · {{known}}/{{count}} execução relatada", + "usageDetail_many": "{{tokens}} tokens · {{known}}/{{count}} execuções relatadas", + "usageDetail_other": "{{tokens}} tokens · {{known}}/{{count}} execuções relatadas" }, "ExternalAutomationManagers": { "e02f970595": "Nenhum gerenciador de automação externa encontrado.", @@ -15022,7 +15029,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Mostrar menos", - "showMore": "Mostrar mais" + "showMore": "Mostrar mais", + "copied": "Copiado", + "copyFailed": "Falha ao copiar", + "copyPrompt": "Copiar prompt" }, "AutomationRunPageFrame": { "40a511bed4": "Contexto da execução", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 594fc386d1d..23612aead57 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -18070,7 +18070,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Свернуть", - "showMore": "Показать ещё" + "showMore": "Показать ещё", + "copied": "Скопировано", + "copyFailed": "Не удалось скопировать", + "copyPrompt": "Копировать запрос" }, "AutomationDetail": { "host": "Сервер", @@ -18111,7 +18114,15 @@ "precheckNone": "Нет", "migrate": "Перенести", "migrateNotice": "{{host}} обновлён до нового удалённого хоста. Перенесите эту автоматизацию, чтобы она продолжала работать.", - "needsMigration": "Требуется перенос" + "needsMigration": "Требуется перенос", + "repeats": "Повтор", + "missedRunGrace": "Допустимая задержка запуска", + "execution": "Выполнение", + "notRunYet": "Ещё не запускалась", + "usageDetail_one": "Токены: {{tokens}} · {{known}}/{{count}} запуск с данными", + "usageDetail_few": "Токены: {{tokens}} · {{known}}/{{count}} запуска с данными", + "usageDetail_many": "Токены: {{tokens}} · {{known}}/{{count}} запусков с данными", + "usageDetail_other": "Токены: {{tokens}} · {{known}}/{{count}} запуска с данными" }, "AutomationEditorDialog": { "fb1896a5e7": "Отмена", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index dc818d8b1c2..9b7911ed8f4 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -17552,7 +17552,10 @@ }, "AutomationPromptDisclosure": { "showLess": "Daha az göster", - "showMore": "Daha fazla göster" + "showMore": "Daha fazla göster", + "copied": "Kopyalandı", + "copyFailed": "Kopyalanamadı", + "copyPrompt": "İstemi kopyala" }, "AutomationDetail": { "host": "Sunucu", @@ -17589,7 +17592,13 @@ "precheckNone": "Yok", "migrate": "Taşı", "migrateNotice": "{{host}} yeni bir uzak ana bilgisayara yükseltildi. Çalışmaya devam etmesi için bu otomasyonu taşıyın.", - "needsMigration": "Taşınması gerekiyor" + "needsMigration": "Taşınması gerekiyor", + "repeats": "Tekrar", + "missedRunGrace": "Kaçırılan çalıştırma toleransı", + "execution": "Çalıştırma", + "notRunYet": "Henüz çalıştırılmadı", + "usageDetail_one": "{{tokens}} token · {{known}}/{{count}} çalıştırma raporlandı", + "usageDetail_other": "{{tokens}} token · {{known}}/{{count}} çalıştırma raporlandı" }, "AutomationEditorDialog": { "fb1896a5e7": "İptal", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index ed1663e76b0..57905b20501 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -17345,7 +17345,10 @@ }, "AutomationPromptDisclosure": { "showLess": "顯示較少", - "showMore": "顯示更多" + "showMore": "顯示更多", + "copied": "已複製", + "copyFailed": "複製失敗", + "copyPrompt": "複製提示詞" }, "AutomationDetail": { "host": "主機", @@ -17380,7 +17383,12 @@ "precheckNone": "無", "migrate": "遷移", "migrateNotice": "{{host}} 已升級為新的遠端主機。請遷移此自動化以繼續執行。", - "needsMigration": "需要遷移" + "needsMigration": "需要遷移", + "repeats": "重複", + "missedRunGrace": "錯過執行寬限期", + "execution": "執行", + "notRunYet": "尚未執行", + "usageDetail_other": "{{tokens}} token · 已回報 {{known}}/{{count}} 次執行" }, "AutomationEditorDialog": { "fb1896a5e7": "取消", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4bd481e1515..d764f29b029 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17189,7 +17189,10 @@ }, "AutomationPromptDisclosure": { "showLess": "收起", - "showMore": "显示更多" + "showMore": "显示更多", + "copied": "已复制", + "copyFailed": "复制失败", + "copyPrompt": "复制提示词" }, "AutomationDetail": { "host": "主机", @@ -17224,7 +17227,12 @@ "precheckNone": "无", "migrate": "迁移", "migrateNotice": "{{host}} 已升级为新的远程主机。请迁移此自动化以继续运行。", - "needsMigration": "需要迁移" + "needsMigration": "需要迁移", + "repeats": "重复", + "missedRunGrace": "错过运行宽限期", + "execution": "执行", + "notRunYet": "尚未运行", + "usageDetail_other": "{{tokens}} Token · 已报告 {{known}}/{{count}} 次运行" }, "AutomationEditorDialog": { "fb1896a5e7": "取消", diff --git a/src/shared/automations/history/automation-run.ts b/src/shared/automations/history/automation-run.ts index ef44d208a2f..49b41d19f38 100644 --- a/src/shared/automations/history/automation-run.ts +++ b/src/shared/automations/history/automation-run.ts @@ -26,6 +26,19 @@ export function isFinalAutomationRunStatus(status: AutomationRunStatus): boolean ) } +/** Folded runs keep their first occurrence's timestamps, so the latest occurrence is when it last ran. */ +export function getAutomationRunLastRunAt( + run: Pick< + AutomationRun, + 'dispatchedAt' | 'startedAt' | 'createdAt' | 'occurrenceCount' | 'lastOccurrenceAt' + > +): number { + const at = run.dispatchedAt ?? run.startedAt ?? run.createdAt + return (run.occurrenceCount ?? 1) > 1 && run.lastOccurrenceAt && run.lastOccurrenceAt > at + ? run.lastOccurrenceAt + : at +} + export type AutomationRunUsageProvider = 'claude' | 'codex' export type AutomationRunUsageStatus = 'known' | 'unavailable' diff --git a/src/shared/automations/history/automation-usage-summary.ts b/src/shared/automations/history/automation-usage-summary.ts index 1c29ddbd544..f9ba5e7b070 100644 --- a/src/shared/automations/history/automation-usage-summary.ts +++ b/src/shared/automations/history/automation-usage-summary.ts @@ -1,4 +1,8 @@ -import type { AutomationRun, AutomationRunStatus } from './automation-run' +import { + getAutomationRunLastRunAt, + type AutomationRun, + type AutomationRunStatus +} from './automation-run' import type { SetupTrustMismatch } from '../../worktree/create-types' export type AutomationUsageSummary = { @@ -65,9 +69,7 @@ export function summarizeAutomationRunUsage( totalTokens, estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null, lastRunStatus: latest?.status ?? null, - lastRunAt: latest - ? (latest.dispatchedAt ?? latest.startedAt ?? latest.createdAt ?? null) - : null, + lastRunAt: latest ? getAutomationRunLastRunAt(latest) : null, ...(latest?.setupSkippedReason === 'setup-changed' ? { lastRunSetupSkipped: true,