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
This commit is contained in:
gsxdsm
2026-06-12 09:46:46 -07:00
parent cdcfa20ce0
commit dcb049587f
9 changed files with 57 additions and 7 deletions

View File

@@ -1852,11 +1852,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
return (
<>
<span className="agent-heartbeat-last text-secondary" title={lastAt.toLocaleString()}>
{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" }) })}
</span>
{isTicking && (
<span className="agent-heartbeat-next text-secondary" title={nextAt.toLocaleString()}>
{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" }) })}
</span>
)}
</>

View File

@@ -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(<AgentsView addToast={mockAddToast} />);
const { container } = render(<AgentsView addToast={mockAddToast} />);
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();
});

View File

@@ -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(<AgentsView addToast={vi.fn()} />);
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(<AgentsView addToast={vi.fn()} />);
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" }));

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "아직 활동 없음",

View File

@@ -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": "暂无活动",

View File

@@ -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": "尚無活動",