mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
d2052167ec
commit
df9b7a5fbe
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user