Skip to content

Commit 6f2721d

Browse files
committed
Schedule pairing QR expiry refresh
1 parent 8a8844f commit 6f2721d

2 files changed

Lines changed: 110 additions & 0 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,6 +1719,40 @@ describe("grouped chat rendering", () => {
17191719
"Run /pair qr again to generate a fresh setup code.",
17201720
);
17211721

1722+
resetAssistantAttachmentAvailabilityCacheForTest();
1723+
vi.useFakeTimers();
1724+
try {
1725+
vi.setSystemTime(new Date("2026-06-30T05:45:00Z"));
1726+
const refreshPairingQr = vi.fn();
1727+
const expiringPairingQrContainer = document.createElement("div");
1728+
renderAssistantMessage(
1729+
expiringPairingQrContainer,
1730+
{
1731+
role: "assistant",
1732+
content: [
1733+
{
1734+
type: "openclaw_pairing_qr",
1735+
image_url: "data:image/png;base64,cXJwbmc=",
1736+
alt: "OpenClaw pairing QR code",
1737+
expiresAtMs: Date.now() + 1_000,
1738+
},
1739+
],
1740+
timestamp: Date.now(),
1741+
},
1742+
{ showToolCalls: false, onRequestUpdate: refreshPairingQr },
1743+
);
1744+
expect(expiringPairingQrContainer.querySelector(".chat-message-image")).not.toBeNull();
1745+
1746+
await vi.advanceTimersByTimeAsync(999);
1747+
expect(refreshPairingQr).not.toHaveBeenCalled();
1748+
1749+
await vi.advanceTimersByTimeAsync(1);
1750+
expect(refreshPairingQr).toHaveBeenCalledTimes(1);
1751+
} finally {
1752+
vi.useRealTimers();
1753+
resetAssistantAttachmentAvailabilityCacheForTest();
1754+
}
1755+
17221756
container = renderUserMedia({
17231757
id: "user-history-image-blocked",
17241758
role: "user",

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,15 @@ type PairingQrExpiryNotice = {
4646
title: string;
4747
reason: string;
4848
};
49+
type PairingQrExpiryRefreshTimer = {
50+
expiresAtMs: number;
51+
onRequestUpdate: () => void;
52+
timer: ReturnType<typeof setTimeout>;
53+
};
4954

5055
const assistantAttachmentAvailabilityCache = new Map<string, AssistantAttachmentAvailability>();
5156
const assistantAttachmentRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
57+
const pairingQrExpiryRefreshTimers = new Map<string, PairingQrExpiryRefreshTimer>();
5258
const ASSISTANT_ATTACHMENT_UNAVAILABLE_RETRY_MS = 5_000;
5359
const ASSISTANT_ATTACHMENT_MEDIA_TICKET_REFRESH_SKEW_MS = 30_000;
5460
const PAIRING_QR_EXPIRED_NOTICE: PairingQrExpiryNotice = {
@@ -114,6 +120,10 @@ export function resetAssistantAttachmentAvailabilityCacheForTest() {
114120
clearTimeout(timer);
115121
}
116122
assistantAttachmentRefreshTimers.clear();
123+
for (const { timer } of pairingQrExpiryRefreshTimers.values()) {
124+
clearTimeout(timer);
125+
}
126+
pairingQrExpiryRefreshTimers.clear();
117127
for (const blobUrl of managedImageBlobUrlResolvedCache.values()) {
118128
URL.revokeObjectURL(blobUrl);
119129
}
@@ -383,6 +393,71 @@ function extractPairingQrExpiryNotices(
383393
return notices;
384394
}
385395

396+
function resolveNearestFuturePairingQrExpiresAtMs(
397+
message: unknown,
398+
nowMs = Date.now(),
399+
): number | undefined {
400+
const m = message as Record<string, unknown>;
401+
const content = m.content;
402+
if (!Array.isArray(content)) {
403+
return undefined;
404+
}
405+
let nearestExpiresAtMs: number | undefined;
406+
for (const block of content) {
407+
if (!block || typeof block !== "object") {
408+
continue;
409+
}
410+
const b = block as Record<string, unknown>;
411+
if (b.type !== "openclaw_pairing_qr") {
412+
continue;
413+
}
414+
const expiresAtMs = readPairingQrExpiresAtMs(b);
415+
if (expiresAtMs === undefined || expiresAtMs <= nowMs) {
416+
continue;
417+
}
418+
nearestExpiresAtMs =
419+
nearestExpiresAtMs === undefined ? expiresAtMs : Math.min(nearestExpiresAtMs, expiresAtMs);
420+
}
421+
return nearestExpiresAtMs;
422+
}
423+
424+
function clearPairingQrExpiryRefreshTimer(messageKey: string) {
425+
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
426+
if (!existing) {
427+
return;
428+
}
429+
clearTimeout(existing.timer);
430+
pairingQrExpiryRefreshTimers.delete(messageKey);
431+
}
432+
433+
function schedulePairingQrExpiryRefresh(
434+
messageKey: string,
435+
message: unknown,
436+
onRequestUpdate: (() => void) | undefined,
437+
) {
438+
const nowMs = Date.now();
439+
const expiresAtMs = resolveNearestFuturePairingQrExpiresAtMs(message, nowMs);
440+
const existing = pairingQrExpiryRefreshTimers.get(messageKey);
441+
if (!expiresAtMs || !onRequestUpdate) {
442+
if (existing) {
443+
clearPairingQrExpiryRefreshTimer(messageKey);
444+
}
445+
return;
446+
}
447+
if (existing?.expiresAtMs === expiresAtMs && existing.onRequestUpdate === onRequestUpdate) {
448+
return;
449+
}
450+
clearPairingQrExpiryRefreshTimer(messageKey);
451+
const timer = setTimeout(
452+
() => {
453+
pairingQrExpiryRefreshTimers.delete(messageKey);
454+
onRequestUpdate();
455+
},
456+
Math.max(0, expiresAtMs - nowMs),
457+
);
458+
pairingQrExpiryRefreshTimers.set(messageKey, { expiresAtMs, onRequestUpdate, timer });
459+
}
460+
386461
function extractTranscriptAttachments(message: unknown): AttachmentItem[] {
387462
const attachments: AttachmentItem[] = [];
388463
for (const { path: mediaPath, mediaType } of extractTranscriptMediaEntries(message)) {
@@ -1735,6 +1810,7 @@ function renderGroupedMessage(
17351810
authToken: opts.assistantAttachmentAuthToken,
17361811
onRequestUpdate: opts.onRequestUpdate,
17371812
};
1813+
schedulePairingQrExpiryRefresh(messageKey, message, opts.onRequestUpdate);
17381814
const images = resolveRenderableMessageImages(extractImages(message), imageRenderOptions);
17391815
const hasImages = images.length > 0;
17401816
const pairingQrExpiryNotices = extractPairingQrExpiryNotices(message);

0 commit comments

Comments
 (0)