From df9b7a5fbe9b94b0ab25dc404db7784797feadca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 15:47:56 -0700 Subject: [PATCH] fix(ui): show accessible last activity on session progress cards (#129520) * fix(ui): show accessible last activity on session progress cards * fix(ui): validate progress activity timestamps at ingress --- docs/tools/progress-card.md | 1 + .../components/session-progress-card.test.ts | 28 +++++++- ui/src/components/session-progress-card.ts | 42 ++++++----- ...ession-progress-live-placement.e2e.test.ts | 50 ++++++++++++- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/session-progress-cards.test.ts | 71 +++++++++++++++++++ ui/src/lib/session-progress-cards.ts | 12 ++-- 7 files changed, 180 insertions(+), 25 deletions(-) create mode 100644 ui/src/lib/session-progress-cards.test.ts diff --git a/docs/tools/progress-card.md b/docs/tools/progress-card.md index fe98001f3347..3fd444fdb8e3 100644 --- a/docs/tools/progress-card.md +++ b/docs/tools/progress-card.md @@ -90,6 +90,7 @@ The current chat shows exactly one live card: - At narrower widths the card appears in the collapsible surface inside the composer. The placements are mutually exclusive. Hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. +Each placement shows the local time of the last progress update, including seconds. ## Pin the card to the dashboard diff --git a/ui/src/components/session-progress-card.test.ts b/ui/src/components/session-progress-card.test.ts index 83d34566de30..50245bd32951 100644 --- a/ui/src/components/session-progress-card.test.ts +++ b/ui/src/components/session-progress-card.test.ts @@ -18,12 +18,38 @@ const progressCard: ProgressCard = { }; describe("renderSessionProgressCard", () => { + it.each(["board", "composer", "dock", "hovercard", "rail"] as const)( + "shows the last activity time for %s cards with and without checklist steps", + (placement) => { + const container = document.createElement("div"); + + for (const steps of [progressCard.steps, undefined]) { + render(renderSessionProgressCard({ ...progressCard, steps }, placement), container); + + const timestamp = container.querySelector(".session-progress-card time"); + expect(timestamp?.getAttribute("datetime")).toBe( + new Date(progressCard.updatedAt).toISOString(), + ); + expect(timestamp?.textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/); + expect(timestamp?.getAttribute("aria-label")).toMatch(/^Last activity: /); + expect(timestamp?.getAttribute("title")).toBe(timestamp?.getAttribute("aria-label")); + const accessibleCard = + placement === "composer" + ? timestamp?.closest("summary") + : timestamp?.closest(".session-progress-card"); + expect(accessibleCard?.getAttribute("aria-label")).toContain( + timestamp?.getAttribute("aria-label"), + ); + } + }, + ); + it("renders sanitized markdown and one accessible typed checklist", () => { const container = document.createElement("div"); render(renderSessionProgressCard(progressCard, "rail"), container); const card = container.querySelector(".session-progress-card"); - expect(card?.getAttribute("aria-label")).toBe("1 of 3 completed"); + expect(card?.getAttribute("aria-label")).toMatch(/^1 of 3 completed\. Last activity: /); expect(card?.querySelector("strong")?.textContent).toBe("Focused change"); expect(card?.querySelector("progress")?.getAttribute("value")).toBe("1"); expect(card?.querySelectorAll(".session-progress-card__count")).toHaveLength(0); diff --git a/ui/src/components/session-progress-card.ts b/ui/src/components/session-progress-card.ts index 793fec7fef3d..faa7fc036710 100644 --- a/ui/src/components/session-progress-card.ts +++ b/ui/src/components/session-progress-card.ts @@ -2,6 +2,7 @@ import type { ProgressCard, ProgressCardStep } from "@openclaw/gateway-protocol" import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { t } from "../i18n/index.ts"; +import { formatTimeMs } from "../lib/format.ts"; import { icons } from "./icons.ts"; import { toSanitizedMarkdownHtml } from "./markdown.ts"; @@ -98,6 +99,19 @@ export function renderSessionProgressCard( total: String(counts.total), }) : t("sessionProgressCard.noteLabel"); + const activityTime = formatTimeMs(card.updatedAt, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); + const activityLabel = t("sessionProgressCard.lastActivity", { time: activityTime }); + const accessibleLabel = `${countLabel}. ${activityLabel}`; + const lastActivity = html``; const dismissible = Boolean( onDismiss && card.steps?.length && card.steps.every((step) => step.status === "completed"), ); @@ -123,7 +137,7 @@ export function renderSessionProgressCard( class="session-progress-card session-progress-card--composer" data-progress-card-placement="composer" > - + ${current?.step ?? t("sessionProgressCard.noteLabel")} - ${counts - ? html`${counts.completed}/${counts.total}` - : nothing} + ${counts ? html`${counts.completed}/${counts.total} ยท ` : nothing}${lastActivity} ${dismiss} @@ -147,17 +159,15 @@ export function renderSessionProgressCard( return html`
- ${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;