Skip to content

Commit b334fbf

Browse files
authored
fix: hide expired pairing QR cards in Control UI (#98049)
* Hide expired pairing QR codes * Schedule pairing QR expiry refresh
1 parent 5f80f12 commit b334fbf

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

ui/src/ui/chat/grouped-render.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,6 +1758,7 @@ describe("grouped chat rendering", () => {
17581758
type: "openclaw_pairing_qr",
17591759
image_url: "data:image/png;base64,cXJwbmc=",
17601760
alt: "OpenClaw pairing QR code",
1761+
expiresAtMs: Date.now() + 60_000,
17611762
},
17621763
],
17631764
timestamp: Date.now(),
@@ -1769,6 +1770,63 @@ describe("grouped chat rendering", () => {
17691770
expect(pairingQrImage?.getAttribute("src")).toBe("data:image/png;base64,cXJwbmc=");
17701771
expect(pairingQrImage?.getAttribute("alt")).toBe("OpenClaw pairing QR code");
17711772

1773+
const expiredPairingQrContainer = document.createElement("div");
1774+
renderAssistantMessage(
1775+
expiredPairingQrContainer,
1776+
{
1777+
role: "assistant",
1778+
content: [
1779+
{
1780+
type: "openclaw_pairing_qr",
1781+
image_url: "data:image/png;base64,ZXhwaXJlZA==",
1782+
alt: "OpenClaw pairing QR code",
1783+
expiresAtMs: Date.now() - 1,
1784+
},
1785+
],
1786+
timestamp: Date.now(),
1787+
},
1788+
{ showToolCalls: false },
1789+
);
1790+
expect(expiredPairingQrContainer.querySelector(".chat-message-image")).toBeNull();
1791+
expect(expiredPairingQrContainer.textContent).toContain("Pairing QR expired");
1792+
expect(expiredPairingQrContainer.textContent).toContain(
1793+
"Run /pair qr again to generate a fresh setup code.",
1794+
);
1795+
1796+
resetAssistantAttachmentAvailabilityCacheForTest();
1797+
vi.useFakeTimers();
1798+
try {
1799+
vi.setSystemTime(new Date("2026-06-30T05:45:00Z"));
1800+
const refreshPairingQr = vi.fn();
1801+
const expiringPairingQrContainer = document.createElement("div");
1802+
renderAssistantMessage(
1803+
expiringPairingQrContainer,
1804+
{
1805+
role: "assistant",
1806+
content: [
1807+
{
1808+
type: "openclaw_pairing_qr",
1809+
image_url: "data:image/png;base64,cXJwbmc=",
1810+
alt: "OpenClaw pairing QR code",
1811+
expiresAtMs: Date.now() + 1_000,
1812+
},
1813+
],
1814+
timestamp: Date.now(),
1815+
},
1816+
{ showToolCalls: false, onRequestUpdate: refreshPairingQr },
1817+
);
1818+
expect(expiringPairingQrContainer.querySelector(".chat-message-image")).not.toBeNull();
1819+
1820+
await vi.advanceTimersByTimeAsync(999);
1821+
expect(refreshPairingQr).not.toHaveBeenCalled();
1822+
1823+
await vi.advanceTimersByTimeAsync(1);
1824+
expect(refreshPairingQr).toHaveBeenCalledTimes(1);
1825+
} finally {
1826+
vi.useRealTimers();
1827+
resetAssistantAttachmentAvailabilityCacheForTest();
1828+
}
1829+
17721830
container = renderUserMedia({
17731831
id: "user-history-image-blocked",
17741832
role: "user",

ui/src/ui/chat/grouped-render.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,25 @@ type AssistantAttachmentAvailability =
4343
| { status: "checking" }
4444
| { status: "available"; mediaTicket?: string; mediaTicketExpiresAt?: number }
4545
| { status: "unavailable"; reason: string; checkedAt: number };
46+
type PairingQrExpiryNotice = {
47+
title: string;
48+
reason: string;
49+
};
50+
type PairingQrExpiryRefreshTimer = {
51+
expiresAtMs: number;
52+
onRequestUpdate: () => void;
53+
timer: ReturnType<typeof setTimeout>;
54+
};
4655

4756
const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachmentAvailability>();
4857
const assistantAttachmentRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
58+
const pairingQrExpiryRefreshTimers = new Map<string, PairingQrExpiryRefreshTimer>();
4959
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
5060
const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000;
61+
const PAIRING_QR_EXPIRED_NOTICE: PairingQrExpiryNotice = {
62+
title: "Pairing QR expired",
63+
reason: "Run /pair qr again to generate a fresh setup code.",
64+
};
5165
let assistantAttachmentAvailabilityRenderVersion = 0;
5266

5367
export type ChatTimestampDisplay = {
@@ -107,6 +121,10 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() {
107121
clearTimeout(timer);
108122
}
109123
assistantAttachmentRefreshTimers.clear();
124+
for (const { timer } of pairingQrExpiryRefreshTimers.values()) {
125+
clearTimeout(timer);
126+
}
127+
pairingQrExpiryRefreshTimers.clear();
110128
for (const blobUrl of managedImageBlobUrlResolvedCache.values()) {
111129
URL.revokeObjectURL(blobUrl);
112130
}
@@ -320,6 +338,9 @@ function extractImages(message: unknown): ImageBlock[] {
320338
});
321339
}
322340
} else if (b.type === "openclaw_pairing_qr") {
341+
if (isExpiredPairingQrBlock(b)) {
342+
continue;
343+
}
323344
const imageUrl = b.image_url;
324345
if (typeof imageUrl === "string") {
325346
appendImageBlock(images, {
@@ -341,6 +362,103 @@ function extractImages(message: unknown): ImageBlock[] {
341362
return images;
342363
}
343364

365+
function readPairingQrExpiresAtMs(block: Record<string, unknown>): number | undefined {
366+
const expiresAtMs = block.expiresAtMs;
367+
return typeof expiresAtMs === "number" && Number.isFinite(expiresAtMs) ? expiresAtMs : undefined;
368+
}
369+
370+
function isExpiredPairingQrBlock(block: Record<string, unknown>, nowMs = Date.now()): boolean {
371+
const expiresAtMs = readPairingQrExpiresAtMs(block);
372+
return expiresAtMs !== undefined && expiresAtMs <= nowMs;
373+
}
374+
375+
function extractPairingQrExpiryNotices(
376+
message: unknown,
377+
nowMs = Date.now(),
378+
): PairingQrExpiryNotice[] {
379+
const m = message as Record<string, unknown>;
380+
const content = m.content;
381+
if (!Array.isArray(content)) {
382+
return [];
383+
}
384+
const notices: PairingQrExpiryNotice[] = [];
385+
for (const block of content) {
386+
if (!block || typeof block !== "object") {
387+
continue;
388+
}
389+
const b = block as Record<string, unknown>;
390+
if (b.type === "openclaw_pairing_qr" && isExpiredPairingQrBlock(b, nowMs)) {
391+
notices.push(PAIRING_QR_EXPIRED_NOTICE);
392+
}
393+
}
394+
return notices;
395+
}
396+
397+
function resolveNearestFuturePairingQrExpiresAtMs(
398+
message: unknown,
399+
nowMs = Date.now(),
400+
): number | undefined {
401+
const m = message as Record<string, unknown>;
402+
const content = m.content;
403+
if (!Array.isArray(content)) {
404+
return undefined;
405+
}
406+
let nearestExpiresAtMs: number | undefined;
407+
for (const block of content) {
408+
if (!block || typeof block !== "object") {
409+
continue;
410+
}
411+
const b = block as Record<string, unknown>;
412+
if (b.type !== "openclaw_pairing_qr") {
413+
continue;
414+
}
415+
const expiresAtMs = readPairingQrExpiresAtMs(b);
416+
if (expiresAtMs === undefined || expiresAtMs <= nowMs) {
417+
continue;
418+
}
419+
nearestExpiresAtMs =
420+
nearestExpiresAtMs === undefined ? expiresAtMs : Math.min(nearestExpiresAtMs, expiresAtMs);
421+
}
422+
return nearestExpiresAtMs;
423+
}
424+
425+
function clearPairingQrExpiryRefreshTimer(messageKey: string) {
426+
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
427+
if (!existing) {
428+
return;
429+
}
430+
clearTimeout(existing.timer);
431+
pairingQrExpiryRefreshTimers.delete(messageKey);
432+
}
433+
434+
function schedulePairingQrExpiryRefresh(
435+
messageKey: string,
436+
message: unknown,
437+
onRequestUpdate: (() => void) | undefined,
438+
) {
439+
const nowMs = Date.now();
440+
const expiresAtMs = resolveNearestFuturePairingQrExpiresAtMs(message, nowMs);
441+
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
442+
if (!expiresAtMs || !onRequestUpdate) {
443+
if (existing) {
444+
clearPairingQrExpiryRefreshTimer(messageKey);
445+
}
446+
return;
447+
}
448+
if (existing?.expiresAtMs === expiresAtMs && existing.onRequestUpdate === onRequestUpdate) {
449+
return;
450+
}
451+
clearPairingQrExpiryRefreshTimer(messageKey);
452+
const timer = setTimeout(
453+
() => {
454+
pairingQrExpiryRefreshTimers.delete(messageKey);
455+
onRequestUpdate();
456+
},
457+
Math.max(0, expiresAtMs - nowMs),
458+
);
459+
pairingQrExpiryRefreshTimers.set(messageKey, { expiresAtMs, onRequestUpdate, timer });
460+
}
461+
344462
function extractTranscriptAttachments(message: unknown): AttachmentItem[] {
345463
const attachments: AttachmentItem[] = [];
346464
for (const { path: mediaPath, mediaType } of extractTranscriptMediaEntries(message)) {
@@ -1020,6 +1138,32 @@ function renderReplyPill(replyTarget: NormalizedMessage["replyTarget"]) {
10201138
`;
10211139
}
10221140

1141+
function renderPairingQrExpiryNotices(notices: PairingQrExpiryNotice[]) {
1142+
if (notices.length === 0) {
1143+
return nothing;
1144+
}
1145+
return html`
1146+
<div class="chat-pairing-qr-notices">
1147+
${notices.map(
1148+
(notice) => html`
1149+
<div
1150+
class="chat-assistant-attachment-card chat-assistant-attachment-card--blocked chat-pairing-qr-expired"
1151+
>
1152+
<div class="chat-assistant-attachment-card__header">
1153+
<span class="chat-assistant-attachment-card__icon">${icons.alertTriangle}</span>
1154+
<span class="chat-assistant-attachment-card__title">${notice.title}</span>
1155+
<span class="chat-assistant-attachment-badge chat-assistant-attachment-badge--muted"
1156+
>Expired</span
1157+
>
1158+
</div>
1159+
<div class="chat-assistant-attachment-card__reason">${notice.reason}</div>
1160+
</div>
1161+
`,
1162+
)}
1163+
</div>
1164+
`;
1165+
}
1166+
10231167
function isLocalAssistantAttachmentSource(source: string): boolean {
10241168
const trimmed = source.trim();
10251169
if (/^\/(?:__openclaw__|media|api\/chat\/media\/outgoing)\//.test(trimmed)) {
@@ -1674,8 +1818,11 @@ function renderGroupedMessage(
16741818
authToken: opts.assistantAttachmentAuthToken,
16751819
onRequestUpdate: opts.onRequestUpdate,
16761820
};
1821+
schedulePairingQrExpiryRefresh(messageKey, message, opts.onRequestUpdate);
16771822
const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions);
16781823
const hasImages = images.length > 0;
1824+
const pairingQrExpiryNotices = extractPairingQrExpiryNotices(message);
1825+
const hasPairingQrExpiryNotices = pairingQrExpiryNotices.length > 0;
16791826

16801827
const normalizedMessage = normalizeMessage(message);
16811828
const extractedText = normalizedMessage.content
@@ -1741,6 +1888,7 @@ function renderGroupedMessage(
17411888
!markdown &&
17421889
!visibleToolCards &&
17431890
!hasImages &&
1891+
!hasPairingQrExpiryNotices &&
17441892
visibleAttachments.length === 0 &&
17451893
assistantViewBlocks.length === 0 &&
17461894
!normalizedMessage.replyTarget
@@ -1845,6 +1993,7 @@ function renderGroupedMessage(
18451993
${toolMessageExpanded
18461994
? html`
18471995
<div class="chat-tool-msg-body">
1996+
${renderPairingQrExpiryNotices(pairingQrExpiryNotices)}
18481997
${renderMessageImages(images, imageRenderOptions)}
18491998
${renderAssistantAttachments(
18501999
visibleAttachments,
@@ -1903,6 +2052,7 @@ function renderGroupedMessage(
19032052
</div>
19042053
`
19052054
: html`
2055+
${renderPairingQrExpiryNotices(pairingQrExpiryNotices)}
19062056
${renderMessageImages(images, imageRenderOptions)}
19072057
${renderAssistantAttachments(
19082058
visibleAttachments,

0 commit comments

Comments
 (0)