- ${counts
- ? html`
- ${t("sessionProgressCard.title")}
-
- ${counts.completed}/${counts.total}
- ${dismiss}
-
-
`
- : nothing}
+
+ ${t("sessionProgressCard.title")}
+
+ ${lastActivity} ${counts ? html`${counts.completed}/${counts.total}` : nothing}
+ ${dismiss}
+
+
${renderBody(card)}
`;
}
diff --git a/ui/src/e2e/session-progress-live-placement.e2e.test.ts b/ui/src/e2e/session-progress-live-placement.e2e.test.ts
index b596b0e27c6a..a42111789fab 100644
--- a/ui/src/e2e/session-progress-live-placement.e2e.test.ts
+++ b/ui/src/e2e/session-progress-live-placement.e2e.test.ts
@@ -1,5 +1,6 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
+import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import {
@@ -35,6 +36,7 @@ const suite = createChatFlowE2eSuite();
suite.define(() => {
it("keeps one live card placement and a compact transcript receipt", async () => {
const sessionKey = "agent:main:progress-placement";
+ const updatedAt = Date.now() - 5 * 60_000;
const plan = [
{ step: "Inspect", status: "completed" },
{ step: "Implement", status: "in_progress" },
@@ -79,7 +81,7 @@ suite.define(() => {
revision: 2,
sessionKey,
steps: plan,
- updatedAt: 2,
+ updatedAt,
},
},
"sessions.list": chatSessionListResponse([
@@ -87,7 +89,7 @@ suite.define(() => {
key: sessionKey,
kind: "direct",
label: "Progress placement",
- updatedAt: 2,
+ updatedAt,
},
]),
},
@@ -98,6 +100,28 @@ suite.define(() => {
await expect.poll(() => gateway.getRequests("progressCard.get")).toHaveLength(1);
const visiblePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--visible");
+ const expectVisibleLastActivity = async (placement: "composer" | "dock" | "rail") => {
+ const card = visiblePane.locator(`[data-progress-card-placement="${placement}"]`);
+ const timestamp = card.locator("time");
+ await expect
+ .poll(() => timestamp.getAttribute("datetime"))
+ .toBe(new Date(updatedAt).toISOString());
+ await expect.poll(() => timestamp.getAttribute("aria-label")).toMatch(/^Last activity: /);
+ await expect.poll(() => timestamp.textContent()).toMatch(/\d{1,2}:\d{2}:\d{2}/);
+ await expect.poll(() => timestamp.isVisible()).toBe(true);
+ const accessibleCard = placement === "composer" ? card.locator("summary") : card;
+ await expect
+ .poll(() => accessibleCard.getAttribute("aria-label"))
+ .toContain("Last activity:");
+ const timestampBounds = await timestamp.boundingBox();
+ const cardBounds = await card.boundingBox();
+ if (!timestampBounds || !cardBounds) {
+ throw new Error("The progress card and last activity time must both remain visible");
+ }
+ expect(timestampBounds.x + timestampBounds.width).toBeLessThanOrEqual(
+ cardBounds.x + cardBounds.width,
+ );
+ };
// Wide enough for the composer gutter to hold the card: it docks beside
// the composer instead of stacking inside it.
await page.setViewportSize({ height: 900, width: 1600 });
@@ -123,6 +147,7 @@ suite.define(() => {
);
})
.toBe(true);
+ await expectVisibleLastActivity("dock");
await captureProof(page, "dock-beside-composer.png");
await page.setViewportSize({ height: 900, width: 1280 });
@@ -143,6 +168,7 @@ suite.define(() => {
await expect
.poll(() => visiblePane.locator(".chat-thread").textContent())
.not.toContain("Implementation is moving.");
+ await expectVisibleLastActivity("rail");
await captureProof(page, "rail-visible.png");
await page.setViewportSize({ height: 900, width: 560 });
@@ -161,6 +187,7 @@ suite.define(() => {
await expect
.poll(() => visiblePane.locator('[data-progress-card-placement="composer"]').isVisible())
.toBe(true);
+ await expectVisibleLastActivity("composer");
await captureProof(page, "composer-adjacent.png");
},
);
@@ -237,8 +264,25 @@ suite.define(() => {
await expectMarkerCentered();
await captureProof(page, `completed-${colorScheme}-before.png`);
+ await gateway.setMethodResponse("progressCard.put", {
+ card: {
+ revision: 4,
+ sessionKey,
+ steps: plan,
+ updatedAt: MAX_DATE_TIMESTAMP_MS + 1,
+ },
+ });
await card.getByRole("button", { name: "Dismiss progress card" }).click();
- const dismissRequest = await gateway.waitForRequest("progressCard.put");
+ await expect.poll(() => gateway.getRequests("progressCard.put")).toHaveLength(1);
+ await page.getByText("Could not dismiss the progress card. Try again.").waitFor();
+ await expect.poll(() => card.isVisible()).toBe(true);
+ await expect
+ .poll(() => card.locator("time").getAttribute("datetime"))
+ .toBe(new Date(3).toISOString());
+
+ await gateway.setMethodResponse("progressCard.put", { card: null });
+ await card.getByRole("button", { name: "Dismiss progress card" }).click();
+ const dismissRequest = await gateway.waitForRequest("progressCard.put", { after: 1 });
expect(dismissRequest.params).toEqual({ sessionKey, expectedRevision: 3 });
await expect.poll(() => card.count()).toBe(0);
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index 7996a010da20..8b9100cfe9ec 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -240,6 +240,7 @@ export const en: TranslationMap = {
widgetUnavailable: "Session progress is unavailable.",
widgetAccessDenied: "Select a session you can access or change sharing for this session.",
countLabel: "{completed} of {total} completed",
+ lastActivity: "Last activity: {time}",
stepLabel: "{step}, {status}",
status: {
completed: "completed",
diff --git a/ui/src/lib/session-progress-cards.test.ts b/ui/src/lib/session-progress-cards.test.ts
new file mode 100644
index 000000000000..38d2331729f5
--- /dev/null
+++ b/ui/src/lib/session-progress-cards.test.ts
@@ -0,0 +1,71 @@
+import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion";
+import { describe, expect, it, vi } from "vitest";
+import type { ApplicationGateway } from "../app/gateway.ts";
+import { sessionProgressCardsForGateway } from "./session-progress-cards.ts";
+
+const sessionKey = "agent:main:progress-date-boundary";
+
+function createProgressCard(updatedAt: number) {
+ return { sessionKey, revision: 1, updatedAt, markdown: "Progress update" };
+}
+
+function createGateway() {
+ const request = vi.fn();
+ const gateway = {
+ snapshot: {
+ client: { request },
+ phase: "connected",
+ hello: { features: { methods: ["progressCard.get", "progressCard.put"] } },
+ },
+ subscribe: () => () => undefined,
+ subscribeEvents: () => () => undefined,
+ } as unknown as ApplicationGateway;
+ return { gateway, request };
+}
+
+describe("session progress card Gateway response boundary", () => {
+ it.each([-MAX_DATE_TIMESTAMP_MS, MAX_DATE_TIMESTAMP_MS])(
+ "accepts the inclusive JavaScript Date boundary %i",
+ async (updatedAt) => {
+ const { gateway, request } = createGateway();
+ request.mockResolvedValueOnce({ card: createProgressCard(updatedAt) });
+
+ const store = sessionProgressCardsForGateway(gateway);
+ await expect(store.load(sessionKey)).resolves.toMatchObject({ updatedAt });
+ expect(store.get(sessionKey)?.updatedAt).toBe(updatedAt);
+ },
+ );
+
+ it.each([-MAX_DATE_TIMESTAMP_MS - 1, MAX_DATE_TIMESTAMP_MS + 1])(
+ "rejects an out-of-range timestamp from progressCard.get: %i",
+ async (updatedAt) => {
+ const { gateway, request } = createGateway();
+ request.mockResolvedValueOnce({ card: createProgressCard(updatedAt) });
+
+ const store = sessionProgressCardsForGateway(gateway);
+ await expect(store.load(sessionKey)).rejects.toThrow(
+ "Progress card response did not match the requested session",
+ );
+ expect(store.get(sessionKey)).toBeUndefined();
+ expect(store.getError(sessionKey)).toBe("unavailable");
+ },
+ );
+
+ it.each([-MAX_DATE_TIMESTAMP_MS - 1, MAX_DATE_TIMESTAMP_MS + 1])(
+ "rejects an out-of-range timestamp from progressCard.put: %i",
+ async (updatedAt) => {
+ const { gateway, request } = createGateway();
+ const existingCard = createProgressCard(Date.now());
+ request
+ .mockResolvedValueOnce({ card: existingCard })
+ .mockResolvedValueOnce({ card: createProgressCard(updatedAt) });
+
+ const store = sessionProgressCardsForGateway(gateway);
+ await store.load(sessionKey);
+ await expect(store.dismiss(existingCard)).rejects.toThrow(
+ "Progress card response did not match the requested session",
+ );
+ expect(store.get(sessionKey)?.updatedAt).toBe(existingCard.updatedAt);
+ },
+ );
+});
diff --git a/ui/src/lib/session-progress-cards.ts b/ui/src/lib/session-progress-cards.ts
index 915a843816ec..a515857dfcf6 100644
--- a/ui/src/lib/session-progress-cards.ts
+++ b/ui/src/lib/session-progress-cards.ts
@@ -4,6 +4,7 @@ import type {
ProgressCardPutResult,
ProgressCardStep,
} from "@openclaw/gateway-protocol";
+import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { GatewayRequestError } from "../api/gateway.ts";
import type { ApplicationGateway } from "../app/gateway.ts";
@@ -60,7 +61,7 @@ function parseProgressCard(value: unknown, sessionKey: string): ProgressCard | n
}
const markdown = card.markdown;
const revision = card.revision;
- const updatedAt = card.updatedAt;
+ const updatedAt = asDateTimestampMs(card.updatedAt);
const rawSteps = card.steps;
if (
card.sessionKey !== sessionKey ||
@@ -69,7 +70,7 @@ function parseProgressCard(value: unknown, sessionKey: string): ProgressCard | n
typeof revision !== "number" ||
!Number.isInteger(revision) ||
revision < 1 ||
- typeof updatedAt !== "number" ||
+ updatedAt === undefined ||
!Number.isInteger(updatedAt)
) {
throw new Error("Progress card response did not match the requested session");
@@ -301,12 +302,13 @@ function createStore(gateway: ApplicationGateway): SessionProgressCardStore {
sessionKey: card.sessionKey,
expectedRevision: card.revision,
});
- const dismissed = result.card === null;
+ const resultCard = parseProgressCard(result, card.sessionKey);
+ const dismissed = resultCard === null;
if (dismissed && cache.get(card.sessionKey)?.revision === card.revision) {
remember(card.sessionKey, { card: null, revision: null });
notify();
- } else if (result.card) {
- remember(card.sessionKey, { card: result.card, revision: result.card.revision });
+ } else if (resultCard) {
+ remember(card.sessionKey, { card: resultCard, revision: resultCard.revision });
notify();
}
return dismissed;