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