diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index b24409ffc52..5f92a1f3d78 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -1758,6 +1758,7 @@ describe("grouped chat rendering", () => { type: "openclaw_pairing_qr", image_url: "data:image/png;base64,cXJwbmc=", alt: "OpenClaw pairing QR code", + expiresAtMs: Date.now() + 60_000, }, ], timestamp: Date.now(), @@ -1769,6 +1770,63 @@ describe("grouped chat rendering", () => { expect(pairingQrImage?.getAttribute("src")).toBe("data:image/png;base64,cXJwbmc="); expect(pairingQrImage?.getAttribute("alt")).toBe("OpenClaw pairing QR code"); + const expiredPairingQrContainer = document.createElement("div"); + renderAssistantMessage( + expiredPairingQrContainer, + { + role: "assistant", + content: [ + { + type: "openclaw_pairing_qr", + image_url: "data:image/png;base64,ZXhwaXJlZA==", + alt: "OpenClaw pairing QR code", + expiresAtMs: Date.now() - 1, + }, + ], + timestamp: Date.now(), + }, + { showToolCalls: false }, + ); + expect(expiredPairingQrContainer.querySelector(".chat-message-image")).toBeNull(); + expect(expiredPairingQrContainer.textContent).toContain("Pairing QR expired"); + expect(expiredPairingQrContainer.textContent).toContain( + "Run /pair qr again to generate a fresh setup code.", + ); + + resetAssistantAttachmentAvailabilityCacheForTest(); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-30T05:45:00Z")); + const refreshPairingQr = vi.fn(); + const expiringPairingQrContainer = document.createElement("div"); + renderAssistantMessage( + expiringPairingQrContainer, + { + role: "assistant", + content: [ + { + type: "openclaw_pairing_qr", + image_url: "data:image/png;base64,cXJwbmc=", + alt: "OpenClaw pairing QR code", + expiresAtMs: Date.now() + 1_000, + }, + ], + timestamp: Date.now(), + }, + { showToolCalls: false, onRequestUpdate: refreshPairingQr }, + ); + expect(expiringPairingQrContainer.querySelector(".chat-message-image")).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(999); + expect(refreshPairingQr).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshPairingQr).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + resetAssistantAttachmentAvailabilityCacheForTest(); + } + container = renderUserMedia({ id: "user-history-image-blocked", role: "user", diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 45a265a1856..e7c0cd45fa2 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -43,11 +43,25 @@ type AssistantAttachmentAvailability = | { status: "checking" } | { status: "available"; mediaTicket?: string; mediaTicketExpiresAt?: number } | { status: "unavailable"; reason: string; checkedAt: number }; +type PairingQrExpiryNotice = { + title: string; + reason: string; +}; +type PairingQrExpiryRefreshTimer = { + expiresAtMs: number; + onRequestUpdate: () => void; + timer: ReturnType; +}; const assistantAttachmentAvailabilityCache = new Map(); const assistantAttachmentRefreshTimers = new Map>(); +const pairingQrExpiryRefreshTimers = new Map(); const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000; const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000; +const PAIRING_QR_EXPIRED_NOTICE: PairingQrExpiryNotice = { + title: "Pairing QR expired", + reason: "Run /pair qr again to generate a fresh setup code.", +}; let assistantAttachmentAvailabilityRenderVersion = 0; export type ChatTimestampDisplay = { @@ -107,6 +121,10 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() { clearTimeout(timer); } assistantAttachmentRefreshTimers.clear(); + for (const { timer } of pairingQrExpiryRefreshTimers.values()) { + clearTimeout(timer); + } + pairingQrExpiryRefreshTimers.clear(); for (const blobUrl of managedImageBlobUrlResolvedCache.values()) { URL.revokeObjectURL(blobUrl); } @@ -320,6 +338,9 @@ function extractImages(message: unknown): ImageBlock[] { }); } } else if (b.type === "openclaw_pairing_qr") { + if (isExpiredPairingQrBlock(b)) { + continue; + } const imageUrl = b.image_url; if (typeof imageUrl === "string") { appendImageBlock(images, { @@ -341,6 +362,103 @@ function extractImages(message: unknown): ImageBlock[] { return images; } +function readPairingQrExpiresAtMs(block: Record): number | undefined { + const expiresAtMs = block.expiresAtMs; + return typeof expiresAtMs === "number" && Number.isFinite(expiresAtMs) ? expiresAtMs : undefined; +} + +function isExpiredPairingQrBlock(block: Record, nowMs = Date.now()): boolean { + const expiresAtMs = readPairingQrExpiresAtMs(block); + return expiresAtMs !== undefined && expiresAtMs <= nowMs; +} + +function extractPairingQrExpiryNotices( + message: unknown, + nowMs = Date.now(), +): PairingQrExpiryNotice[] { + const m = message as Record; + const content = m.content; + if (!Array.isArray(content)) { + return []; + } + const notices: PairingQrExpiryNotice[] = []; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const b = block as Record; + if (b.type === "openclaw_pairing_qr" && isExpiredPairingQrBlock(b, nowMs)) { + notices.push(PAIRING_QR_EXPIRED_NOTICE); + } + } + return notices; +} + +function resolveNearestFuturePairingQrExpiresAtMs( + message: unknown, + nowMs = Date.now(), +): number | undefined { + const m = message as Record; + const content = m.content; + if (!Array.isArray(content)) { + return undefined; + } + let nearestExpiresAtMs: number | undefined; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const b = block as Record; + if (b.type !== "openclaw_pairing_qr") { + continue; + } + const expiresAtMs = readPairingQrExpiresAtMs(b); + if (expiresAtMs === undefined || expiresAtMs <= nowMs) { + continue; + } + nearestExpiresAtMs = + nearestExpiresAtMs === undefined ? expiresAtMs : Math.min(nearestExpiresAtMs, expiresAtMs); + } + return nearestExpiresAtMs; +} + +function clearPairingQrExpiryRefreshTimer(messageKey: string) { + const existing = pairingQrExpiryRefreshTimers.get(messageKey); + if (!existing) { + return; + } + clearTimeout(existing.timer); + pairingQrExpiryRefreshTimers.delete(messageKey); +} + +function schedulePairingQrExpiryRefresh( + messageKey: string, + message: unknown, + onRequestUpdate: (() => void) | undefined, +) { + const nowMs = Date.now(); + const expiresAtMs = resolveNearestFuturePairingQrExpiresAtMs(message, nowMs); + const existing = pairingQrExpiryRefreshTimers.get(messageKey); + if (!expiresAtMs || !onRequestUpdate) { + if (existing) { + clearPairingQrExpiryRefreshTimer(messageKey); + } + return; + } + if (existing?.expiresAtMs === expiresAtMs && existing.onRequestUpdate === onRequestUpdate) { + return; + } + clearPairingQrExpiryRefreshTimer(messageKey); + const timer = setTimeout( + () => { + pairingQrExpiryRefreshTimers.delete(messageKey); + onRequestUpdate(); + }, + Math.max(0, expiresAtMs - nowMs), + ); + pairingQrExpiryRefreshTimers.set(messageKey, { expiresAtMs, onRequestUpdate, timer }); +} + function extractTranscriptAttachments(message: unknown): AttachmentItem[] { const attachments: AttachmentItem[] = []; for (const { path: mediaPath, mediaType } of extractTranscriptMediaEntries(message)) { @@ -1020,6 +1138,32 @@ function renderReplyPill(replyTarget: NormalizedMessage["replyTarget"]) { `; } +function renderPairingQrExpiryNotices(notices: PairingQrExpiryNotice[]) { + if (notices.length === 0) { + return nothing; + } + return html` +
+ ${notices.map( + (notice) => html` +
+
+ ${icons.alertTriangle} + ${notice.title} + Expired +
+
${notice.reason}
+
+ `, + )} +
+ `; +} + function isLocalAssistantAttachmentSource(source: string): boolean { const trimmed = source.trim(); if (/^\/(?:__openclaw__|media|api\/chat\/media\/outgoing)\//.test(trimmed)) { @@ -1674,8 +1818,11 @@ function renderGroupedMessage( authToken: opts.assistantAttachmentAuthToken, onRequestUpdate: opts.onRequestUpdate, }; + schedulePairingQrExpiryRefresh(messageKey, message, opts.onRequestUpdate); const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions); const hasImages = images.length > 0; + const pairingQrExpiryNotices = extractPairingQrExpiryNotices(message); + const hasPairingQrExpiryNotices = pairingQrExpiryNotices.length > 0; const normalizedMessage = normalizeMessage(message); const extractedText = normalizedMessage.content @@ -1741,6 +1888,7 @@ function renderGroupedMessage( !markdown && !visibleToolCards && !hasImages && + !hasPairingQrExpiryNotices && visibleAttachments.length === 0 && assistantViewBlocks.length === 0 && !normalizedMessage.replyTarget @@ -1845,6 +1993,7 @@ function renderGroupedMessage( ${toolMessageExpanded ? html`
+ ${renderPairingQrExpiryNotices(pairingQrExpiryNotices)} ${renderMessageImages(images, imageRenderOptions)} ${renderAssistantAttachments( visibleAttachments, @@ -1903,6 +2052,7 @@ function renderGroupedMessage(
` : html` + ${renderPairingQrExpiryNotices(pairingQrExpiryNotices)} ${renderMessageImages(images, imageRenderOptions)} ${renderAssistantAttachments( visibleAttachments,