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
This commit is contained in:
Peter Steinberger
2026-08-25 15:47:56 -07:00
committed by GitHub
parent d2052167ec
commit df9b7a5fbe
7 changed files with 180 additions and 25 deletions
+1
View File
@@ -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
@@ -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);
+26 -16
View File
@@ -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`<time
datetime=${new Date(card.updatedAt).toISOString()}
aria-label=${activityLabel}
title=${activityLabel}
>${activityTime}</time
>`;
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"
>
<summary class="session-progress-card__summary" aria-label=${countLabel}>
<summary class="session-progress-card__summary" aria-label=${accessibleLabel}>
<span
class="session-progress-card__current-marker"
data-status=${currentStatus}
@@ -133,11 +147,9 @@ export function renderSessionProgressCard(
<span class="session-progress-card__current"
>${current?.step ?? t("sessionProgressCard.noteLabel")}</span
>
${counts
? html`<span class="session-progress-card__count"
>${counts.completed}/${counts.total}</span
>`
: nothing}
<span class="session-progress-card__count"
>${counts ? html`${counts.completed}/${counts.total} · ` : nothing}${lastActivity}</span
>
${dismiss}
<span class="session-progress-card__chevron" aria-hidden="true">${icons.chevronDown}</span>
</summary>
@@ -147,17 +159,15 @@ export function renderSessionProgressCard(
return html`<section
class="session-progress-card session-progress-card--${placement}"
data-progress-card-placement=${placement}
aria-label=${countLabel}
aria-label=${accessibleLabel}
>
${counts
? html`<div class="session-progress-card__heading">
<span>${t("sessionProgressCard.title")}</span>
<span class="session-progress-card__heading-actions">
<span>${counts.completed}/${counts.total}</span>
${dismiss}
</span>
</div>`
: nothing}
<div class="session-progress-card__heading">
<span>${t("sessionProgressCard.title")}</span>
<span class="session-progress-card__heading-actions">
${lastActivity} ${counts ? html`<span>${counts.completed}/${counts.total}</span>` : nothing}
${dismiss}
</span>
</div>
${renderBody(card)}
</section>`;
}
@@ -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);
+1
View File
@@ -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",
+71
View File
@@ -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);
},
);
});
+7 -5
View File
@@ -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;