From dcb049587fee42763fbaa41d2aaceda1aeb75dfd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 12 Jun 2026 09:46:46 -0700 Subject: [PATCH] FN-6298: fix agent heartbeat timestamp badges Fix agent heartbeat card badges to use dedicated timestamp translation keys. - Use separate Last/Next heartbeat timestamp keys so existing label strings do not collide with interpolated badge text. - Add localized timestamp strings for all app locales. - Cover desktop and mobile agent cards against raw placeholder or missing timestamp regressions. Files changed: packages/dashboard/app/components/AgentsView.tsx | 4 ++-- .../app/components/__tests__/AgentsView.test.tsx | 25 +++++++++++++++++++--- .../__tests__/agents-view-mobile.test.tsx | 23 ++++++++++++++++++-- packages/i18n/locales/en/app.json | 2 ++ packages/i18n/locales/es/app.json | 2 ++ packages/i18n/locales/fr/app.json | 2 ++ packages/i18n/locales/ko/app.json | 2 ++ packages/i18n/locales/zh-CN/app.json | 2 ++ packages/i18n/locales/zh-TW/app.json | 2 ++ 9 files changed, 57 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6298 Fusion-Task-Lineage: 1561bcdc-83c9-4a81-aa7b-09d0c441ac1a --- .../dashboard/app/components/AgentsView.tsx | 4 +-- .../components/__tests__/AgentsView.test.tsx | 25 ++++++++++++++++--- .../__tests__/agents-view-mobile.test.tsx | 23 +++++++++++++++-- packages/i18n/locales/en/app.json | 2 ++ packages/i18n/locales/es/app.json | 2 ++ packages/i18n/locales/fr/app.json | 2 ++ packages/i18n/locales/ko/app.json | 2 ++ packages/i18n/locales/zh-CN/app.json | 2 ++ packages/i18n/locales/zh-TW/app.json | 2 ++ 9 files changed, 57 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 773777e6de..df64775462 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -1852,11 +1852,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return ( <> - {t("agents.lastHeartbeat", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} + {t("agents.lastHeartbeatAt", "Last: {{time}}", { time: lastAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} {isTicking && ( - {t("agents.nextHeartbeat", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} + {t("agents.nextHeartbeatAt", "Next: {{time}}", { time: nextAt.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) })} )} diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 3ba51cd0d0..bed6c69404 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -1,5 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import i18next from "i18next"; import { loadAllAppCss } from "../../test/cssFixture"; import { AgentsView } from "../AgentsView"; import * as apiModule from "../../api"; @@ -184,6 +185,10 @@ describe("AgentsView", () => { mockUpdateSettings.mockResolvedValue({}); }); + afterEach(() => { + i18next.removeResourceBundle("en", "app"); + }); + const openControlsPanel = async () => { const trigger = await screen.findByRole("button", { name: "Controls" }); fireEvent.click(trigger); @@ -817,7 +822,14 @@ describe("AgentsView", () => { expect(options).not.toContain("1m"); }); - it("renders Last/Next heartbeat timestamps without seconds", async () => { + it("renders Last/Next heartbeat timestamps without seconds when old catalog keys collide", async () => { + i18next.addResourceBundle( + "en", + "app", + { agents: { lastHeartbeat: "Last heartbeat", nextHeartbeat: "Next heartbeat in {{elapsed}}" } }, + true, + true, + ); const lastHeartbeatAt = "2026-05-04T14:23:45.000Z"; mockFetchAgents.mockResolvedValueOnce([ { @@ -828,7 +840,7 @@ describe("AgentsView", () => { ]); mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: { active: 1 }, byRole: { triage: 1 } }); - render(); + const { container } = render(); const lastAt = new Date(lastHeartbeatAt); const nextAt = new Date(lastAt.getTime() + 300000); @@ -840,6 +852,13 @@ describe("AgentsView", () => { expect(screen.getByText(expectedNext)).toBeTruthy(); }); + const lastBadge = container.querySelector(".agent-heartbeat-last"); + const nextBadge = container.querySelector(".agent-heartbeat-next"); + expect(lastBadge?.textContent).toMatch(/Last: .*\d/); + expect(lastBadge?.textContent).not.toBe("Last heartbeat"); + expect(nextBadge?.textContent).toMatch(/Next: .*\d/); + expect(nextBadge?.textContent).not.toContain("{{"); + expect(nextBadge?.textContent).not.toContain("{{elapsed}}"); expect(screen.queryByText(/Last: .*:\d{2}:\d{2}/)).toBeNull(); expect(screen.queryByText(/Next: .*:\d{2}:\d{2}/)).toBeNull(); }); diff --git a/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx b/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx index 34c6307bb7..f09b201c51 100644 --- a/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/agents-view-mobile.test.tsx @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import i18next from "i18next"; import { AgentsView } from "../AgentsView"; import { loadAllAppCss } from "../../test/cssFixture"; import type { Agent, AgentCapability, AgentState } from "../../api"; @@ -149,6 +150,10 @@ describe("AgentsView mobile adaptations", () => { vi.mocked(fetchOrgTree).mockResolvedValue([]); }); + afterEach(() => { + i18next.removeResourceBundle("en", "app"); + }); + it("renders board view grid and board cards", async () => { const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); @@ -161,7 +166,14 @@ describe("AgentsView mobile adaptations", () => { }); }); - it("renders list view cards", async () => { + it("renders list view cards with interpolated heartbeat badges", async () => { + i18next.addResourceBundle( + "en", + "app", + { agents: { lastHeartbeat: "Last heartbeat", nextHeartbeat: "Next heartbeat in {{elapsed}}" } }, + true, + true, + ); const { container } = render(); await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy()); @@ -172,6 +184,13 @@ describe("AgentsView mobile adaptations", () => { expect(container.querySelectorAll(".agent-card").length).toBeGreaterThan(0); }); + const lastBadge = container.querySelector(".agent-heartbeat-last"); + const nextBadge = container.querySelector(".agent-heartbeat-next"); + expect(lastBadge?.textContent).toMatch(/Last: .*\d/); + expect(lastBadge?.textContent).not.toBe("Last heartbeat"); + expect(nextBadge?.textContent).toMatch(/Next: .*\d/); + expect(nextBadge?.textContent).not.toContain("{{"); + // Token-stats panel now lives in the controls popup; open it before // asserting on the panel content. fireEvent.click(screen.getByRole("button", { name: "Controls" })); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 46d83967d6..09e808e1b1 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -564,6 +564,7 @@ "last24h": "Last 24h", "last7d": "Last 7 days", "lastHeartbeat": "Last heartbeat", + "lastHeartbeatAt": "Last: {{time}}", "latestRunLabel": "Latest run", "layoutAuto": "Auto", "layoutAutoAria": "Automatic layout", @@ -656,6 +657,7 @@ "next": "Next", "nextExpected": "Next expected", "nextHeartbeat": "Next heartbeat in {{elapsed}}", + "nextHeartbeatAt": "Next: {{time}}", "noActiveAssignment": "No active assignment", "noActiveEligible": "No active agents eligible to pause", "noActivityYet": "No activity yet", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 70cb8217df..fc7a243e8a 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -564,6 +564,7 @@ "last24h": "Últimas 24h", "last7d": "Últimos 7 días", "lastHeartbeat": "Última: {{time}}", + "lastHeartbeatAt": "Última: {{time}}", "latestRunLabel": "Última ejecución", "layoutAuto": "Auto", "layoutAutoAria": "Diseño automático", @@ -656,6 +657,7 @@ "next": "Siguiente", "nextExpected": "Próximo esperado", "nextHeartbeat": "Próxima: {{time}}", + "nextHeartbeatAt": "Próxima: {{time}}", "noActiveAssignment": "Sin asignación activa", "noActiveEligible": "Sin agentes activos elegibles para pausar", "noActivityYet": "Sin actividad aún", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index ec222a4a0c..aaca4411c1 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -564,6 +564,7 @@ "last24h": "Dernières 24h", "last7d": "7 derniers jours", "lastHeartbeat": "Dernière : {{time}}", + "lastHeartbeatAt": "Dernière : {{time}}", "latestRunLabel": "Dernière exécution", "layoutAuto": "Auto", "layoutAutoAria": "Disposition automatique", @@ -656,6 +657,7 @@ "next": "Suivant", "nextExpected": "Prochain attendu", "nextHeartbeat": "Prochaine : {{time}}", + "nextHeartbeatAt": "Prochaine : {{time}}", "noActiveAssignment": "Aucune assignation active", "noActiveEligible": "Aucun agent actif éligible à la pause", "noActivityYet": "Aucune activité pour l'instant", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 3073a36b9f..56646c5d09 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -558,6 +558,7 @@ "last24h": "최근 24시간", "last7d": "최근 7일", "lastHeartbeat": "마지막 하트비트", + "lastHeartbeatAt": "", "latestRunLabel": "최신 실행", "layoutAuto": "자동", "layoutAutoAria": "자동 레이아웃", @@ -649,6 +650,7 @@ "next": "다음", "nextExpected": "다음 예정", "nextHeartbeat": "다음 하트비트까지 {{elapsed}}", + "nextHeartbeatAt": "", "noActiveAssignment": "활성 할당 없음", "noActiveEligible": "일시정지 가능한 활성 에이전트 없음", "noActivityYet": "아직 활동 없음", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 4b65be97bd..c7b990b999 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -558,6 +558,7 @@ "last24h": "最近 24 小时", "last7d": "最近 7 天", "lastHeartbeat": "上次:{{time}}", + "lastHeartbeatAt": "上次:{{time}}", "latestRunLabel": "最新运行", "layoutAuto": "自动", "layoutAutoAria": "自动布局", @@ -649,6 +650,7 @@ "next": "下一步", "nextExpected": "下次预期", "nextHeartbeat": "下次:{{time}}", + "nextHeartbeatAt": "下次:{{time}}", "noActiveAssignment": "无活动任务", "noActiveEligible": "没有符合条件可暂停的活动代理", "noActivityYet": "暂无活动", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 465d7ecc97..27b79bedc2 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -558,6 +558,7 @@ "last24h": "最近 24 小時", "last7d": "最近 7 天", "lastHeartbeat": "上次:{{time}}", + "lastHeartbeatAt": "上次:{{time}}", "latestRunLabel": "最新執行", "layoutAuto": "自動", "layoutAutoAria": "自動版面", @@ -649,6 +650,7 @@ "next": "下一步", "nextExpected": "下次預期", "nextHeartbeat": "下次:{{time}}", + "nextHeartbeatAt": "下次:{{time}}", "noActiveAssignment": "無活動任務", "noActiveEligible": "沒有符合條件可暫停的活動代理", "noActivityYet": "尚無活動",