From 3b5258365c4abc29ebf964f411696aa5562d6310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 14 Sep 2026 23:08:58 -0700 Subject: [PATCH] refactor(ui): simplify browser access and media recovery (#148811) --- config/assertion-safety-baseline.txt | 2 +- ui/public/sw.js | 30 ++-- ui/src/app/browser-auth-recovery.test.ts | 151 ++++++++---------- ui/src/app/browser-auth-recovery.ts | 14 +- ui/src/app/service-worker-cache.test.ts | 1 + ui/src/e2e/browser-auth-recovery.e2e.test.ts | 21 +-- .../chat/components/chat-media-source.ts | 16 +- .../chat-message-attachment-availability.ts | 3 +- .../chat/components/chat-message-images.ts | 33 ++-- .../chat-message-media-lifecycle.test.ts | 9 +- .../chat/components/chat-message-media.ts | 25 ++- 11 files changed, 134 insertions(+), 171 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index ac36dff013f5..792e8fe925d7 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3756,7 +3756,7 @@ ui/src/pages/chat/components/chat-header-session-menu.ts 1 ui/src/pages/chat/components/chat-message-attachment-availability.ts 1 ui/src/pages/chat/components/chat-message-bubble.ts 1 ui/src/pages/chat/components/chat-message-confirmation.ts 2 -ui/src/pages/chat/components/chat-message-media.ts 3 +ui/src/pages/chat/components/chat-message-media.ts 1 ui/src/pages/chat/components/chat-message-timestamp.ts 4 ui/src/pages/chat/components/chat-model-picker-options.ts 2 ui/src/pages/chat/components/chat-model-picker.ts 5 diff --git a/ui/public/sw.js b/ui/public/sw.js index e6d3e19542f0..d33e3cd6e340 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -1,6 +1,3 @@ -// OpenClaw Control – Service Worker -// Handles offline caching and push notifications. - const CACHE_PREFIX = "openclaw-control-"; const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"; const URL_CACHE_VERSION = new URL(self.location.href).searchParams @@ -12,17 +9,17 @@ const CACHE_VERSION = : URL_CACHE_VERSION) || "dev"; const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`; const CONTROL_CACHE_LIMIT = 3; +const SCOPE_URL = new URL(self.registration.scope); +const SCOPE_PATH = SCOPE_URL.pathname.endsWith("/") ? SCOPE_URL.pathname : `${SCOPE_URL.pathname}/`; function controlUiPathname(url) { - const scopeUrl = new URL(self.registration.scope); - const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`; - if (url.origin !== scopeUrl.origin) { + if (url.origin !== SCOPE_URL.origin) { return null; } - if (url.pathname === scopeUrl.pathname) { + if (url.pathname === SCOPE_URL.pathname) { return "/"; } - return url.pathname.startsWith(scopePath) ? `/${url.pathname.slice(scopePath.length)}` : null; + return url.pathname.startsWith(SCOPE_PATH) ? `/${url.pathname.slice(SCOPE_PATH.length)}` : null; } // Older pages reload directly and cannot acquire new config-draft guards. Keep @@ -40,7 +37,6 @@ self.addEventListener("message", (event) => { } }); -// Minimal app-shell files to precache. const PRECACHE_URLS = ["./"]; self.addEventListener("install", (event) => { @@ -186,27 +182,21 @@ self.addEventListener("push", (event) => { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const scopeUrl = new URL(self.registration.scope); - const scopePath = scopeUrl.pathname.endsWith("/") ? scopeUrl.pathname : `${scopeUrl.pathname}/`; // Relative targets belong beneath the registered scope even when its URL // omits a trailing slash; keep the exact scope for default navigation. - const scopeNavigationBase = new URL(scopePath, scopeUrl); + const scopeNavigationBase = new URL(SCOPE_PATH, SCOPE_URL); const notificationUrl = event.notification.data?.url; // Notifications shown before an update stored "./" for implicit targets. // Preserve their existing in-scope tabs when the new worker handles the click. const hasExplicitTarget = event.notification.data?.explicitUrl ?? Boolean(notificationUrl && notificationUrl !== "./"); - const isInScope = (url) => - url.origin === scopeUrl.origin && - (url.pathname === scopeUrl.pathname || url.pathname.startsWith(scopePath)); - - let targetUrl = scopeUrl; + let targetUrl = SCOPE_URL; try { const requestedUrl = new URL( - (hasExplicitTarget ? notificationUrl : undefined) || scopeUrl.href, + (hasExplicitTarget ? notificationUrl : undefined) || SCOPE_URL.href, scopeNavigationBase, ); - if (isInScope(requestedUrl)) { + if (controlUiPathname(requestedUrl) !== null) { targetUrl = requestedUrl; } } catch { @@ -225,7 +215,7 @@ self.addEventListener("notificationclick", (event) => { continue; } - if (!isInScope(clientUrl)) { + if (controlUiPathname(clientUrl) === null) { continue; } if (!hasExplicitTarget) { diff --git a/ui/src/app/browser-auth-recovery.test.ts b/ui/src/app/browser-auth-recovery.test.ts index 07f6a0b8c445..1636ea833a3d 100644 --- a/ui/src/app/browser-auth-recovery.test.ts +++ b/ui/src/app/browser-auth-recovery.test.ts @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts"; import { startBrowserAuthRecovery } from "./browser-auth-recovery.ts"; import { fetchControlUiResource, subscribeBrowserAuthRestored } from "./browser-http.ts"; @@ -52,48 +53,44 @@ describe("browser sign-in recovery", () => { }); vi.stubGlobal("fetch", fetchMock); const restored = vi.fn(); - const unsubscribe = subscribeBrowserAuthRestored(restored); + onTestFinished(subscribeBrowserAuthRestored(restored)); const opened = vi.spyOn(window, "open").mockReturnValue(null); const initialLocation = window.location.href; - try { - const requests = ["image-a", "image-b"].map((name) => - fetchControlUiResource(`/nested/__openclaw__/assistant-media?source=${name}`), - ); - const results = await Promise.allSettled(requests); - expect(results.every((result) => result.status === "rejected")).toBe(true); - await expect.poll(() => document.querySelector("openclaw-modal-dialog")).not.toBeNull(); - const { dialog } = await getRenderedModalDialog(document.body); - expect(dialog.getAttribute("aria-label")).toBe("Sign in to continue loading content"); - expect(document.querySelectorAll("openclaw-modal-dialog")).toHaveLength(1); - expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "HEAD")).toEqual([ - [ - `${window.location.origin}/nested/control-ui-config.json`, - expect.objectContaining({ - redirect: "manual", - cache: "no-store", - credentials: "same-origin", - }), - ], - ]); + const requests = ["image-a", "image-b"].map((name) => + fetchControlUiResource(`/nested/__openclaw__/assistant-media?source=${name}`), + ); + const results = await Promise.allSettled(requests); + expect(results.every((result) => result.status === "rejected")).toBe(true); + await expect.poll(() => document.querySelector("openclaw-modal-dialog")).not.toBeNull(); + const { dialog } = await getRenderedModalDialog(document.body); + expect(dialog.getAttribute("aria-label")).toBe("Sign in to continue loading content"); + expect(document.querySelectorAll("openclaw-modal-dialog")).toHaveLength(1); + expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "HEAD")).toEqual([ + [ + `${window.location.origin}/nested/control-ui-config.json`, + expect.objectContaining({ + redirect: "manual", + cache: "no-store", + credentials: "same-origin", + }), + ], + ]); - button("Sign in").click(); - expect(opened).toHaveBeenCalledExactlyOnceWith( - `${window.location.origin}/nested/`, - "_blank", - "noopener,noreferrer", - ); - button("Check again").click(); - await expect.poll(() => document.body.textContent).toContain("Sign-in is still required"); - expect(restored).not.toHaveBeenCalled(); + button("Sign in").click(); + expect(opened).toHaveBeenCalledExactlyOnceWith( + `${window.location.origin}/nested/`, + "_blank", + "noopener,noreferrer", + ); + button("Check again").click(); + await expect.poll(() => document.body.textContent).toContain("Sign-in is still required"); + expect(restored).not.toHaveBeenCalled(); - authenticated = true; - window.dispatchEvent(new Event("focus")); - await expect.poll(() => restored.mock.calls.length).toBe(1); - expect(document.querySelector("openclaw-modal-dialog")).toBeNull(); - expect(window.location.href).toBe(initialLocation); - } finally { - unsubscribe(); - } + authenticated = true; + window.dispatchEvent(new Event("focus")); + await expect.poll(() => restored.mock.calls.length).toBe(1); + expect(document.querySelector("openclaw-modal-dialog")).toBeNull(); + expect(window.location.href).toBe(initialLocation); }); it.each(["offline", "missing", "server-error", "gateway-auth", "html"])( @@ -155,24 +152,20 @@ describe("browser sign-in recovery", () => { }), ); const restored = vi.fn(); - const unsubscribe = subscribeBrowserAuthRestored(restored); - try { - await expect( - fetchControlUiResource("/nested/__openclaw__/assistant-media"), - ).rejects.toThrow(); - await getRenderedModalDialog(document.body); - signedIn = true; - window.dispatchEvent(new Event("focus")); - await expect.poll(() => restored.mock.calls.length).toBe(1); - expect(credentials).toEqual([ - "Bearer expired-device-token", - "Bearer stored-token", - ...(acceptedCredential === "stored-password" ? ["Bearer stored-password"] : []), - ]); - expect(document.querySelector("openclaw-modal-dialog")).toBeNull(); - } finally { - unsubscribe(); - } + onTestFinished(subscribeBrowserAuthRestored(restored)); + await expect( + fetchControlUiResource("/nested/__openclaw__/assistant-media"), + ).rejects.toThrow(); + await getRenderedModalDialog(document.body); + signedIn = true; + window.dispatchEvent(new Event("focus")); + await expect.poll(() => restored.mock.calls.length).toBe(1); + expect(credentials).toEqual([ + "Bearer expired-device-token", + "Bearer stored-token", + ...(acceptedCredential === "stored-password" ? ["Bearer stored-password"] : []), + ]); + expect(document.querySelector("openclaw-modal-dialog")).toBeNull(); }, ); @@ -181,7 +174,7 @@ describe("browser sign-in recovery", () => { let token = "original-token"; vi.spyOn(window, "open").mockReturnValue(null); stop = startBrowserAuthRecovery("/nested", () => ({ settings: { token } })); - let finishProbe!: (response: Response) => void; + const probe = createDeferred(); const success = () => new Response(null, { headers: { "content-type": "application/json" } }); let probes = 0; vi.stubGlobal( @@ -195,33 +188,25 @@ describe("browser sign-in recovery", () => { return redirectResponse(); } if (probes === 2) { - return new Promise((resolve) => { - finishProbe = resolve; - }); + return probe.promise; } expect(new Headers(init.headers).get("Authorization")).toBe("Bearer replacement-token"); return success(); }), ); const restored = vi.fn(); - const unsubscribe = subscribeBrowserAuthRestored(restored); - try { - await expect( - fetchControlUiResource("/nested/__openclaw__/assistant-media"), - ).rejects.toThrow(); - await getRenderedModalDialog(document.body); - button("Sign in").click(); - window.dispatchEvent(new Event("focus")); - token = "replacement-token"; - finishProbe(success()); - await expect.poll(() => button("Check again").disabled).toBe(false); - expect(restored).not.toHaveBeenCalled(); - expect(document.querySelector("openclaw-modal-dialog")).not.toBeNull(); - button("Check again").click(); - await expect.poll(() => restored.mock.calls.length).toBe(1); - } finally { - unsubscribe(); - } + onTestFinished(subscribeBrowserAuthRestored(restored)); + await expect(fetchControlUiResource("/nested/__openclaw__/assistant-media")).rejects.toThrow(); + await getRenderedModalDialog(document.body); + button("Sign in").click(); + window.dispatchEvent(new Event("focus")); + token = "replacement-token"; + probe.resolve(success()); + await expect.poll(() => button("Check again").disabled).toBe(false); + expect(restored).not.toHaveBeenCalled(); + expect(document.querySelector("openclaw-modal-dialog")).not.toBeNull(); + button("Check again").click(); + await expect.poll(() => restored.mock.calls.length).toBe(1); }); it("keeps dismissal quiet across later automatic attachment retries", async () => { @@ -256,21 +241,19 @@ describe("browser sign-in recovery", () => { }); it("discards an in-flight probe when its document owner stops", async () => { - let finishProbe: (response: Response) => void = () => {}; + const probe = createDeferred(); vi.stubGlobal( "fetch", vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === "HEAD") { - return new Promise((resolve) => { - finishProbe = resolve; - }); + return probe.promise; } throw new TypeError("Failed to fetch"); }), ); await expect(fetchControlUiResource("/nested/__openclaw__/assistant-media")).rejects.toThrow(); stop(); - finishProbe(redirectResponse()); + probe.resolve(redirectResponse()); await Promise.resolve(); await Promise.resolve(); expect(document.querySelector("openclaw-modal-dialog")).toBeNull(); diff --git a/ui/src/app/browser-auth-recovery.ts b/ui/src/app/browser-auth-recovery.ts index 33d2a14108b4..6b7130bc467d 100644 --- a/ui/src/app/browser-auth-recovery.ts +++ b/ui/src/app/browser-auth-recovery.ts @@ -104,7 +104,7 @@ export function startBrowserAuthRecovery( current.every((candidate, index) => candidate === authCandidates[index]) ); }; - const probe = (async () => { + pending = (async () => { try { // This canonical endpoint never redirects. Manual mode exposes an edge // redirect without following it or forwarding Gateway credentials to it. @@ -147,8 +147,7 @@ export function startBrowserAuthRecovery( probeResult = "unavailable"; } } - })(); - pending = probe.finally(() => { + })().finally(() => { pending = undefined; dialog?.update(); }); @@ -187,14 +186,11 @@ export function startBrowserAuthRecovery( onFocus(); } }; - serviceWorker?.addEventListener("message", onWorkerMessage); - window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisibilityChange); + serviceWorker?.addEventListener("message", onWorkerMessage, { signal: lifetime.signal }); + window.addEventListener("focus", onFocus, { signal: lifetime.signal }); + document.addEventListener("visibilitychange", onVisibilityChange, { signal: lifetime.signal }); return () => { lifetime.abort(); stopFailures(); - serviceWorker?.removeEventListener("message", onWorkerMessage); - window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisibilityChange); }; } diff --git a/ui/src/app/service-worker-cache.test.ts b/ui/src/app/service-worker-cache.test.ts index 517c6e3b805c..8f569f2ef2b5 100644 --- a/ui/src/app/service-worker-cache.test.ts +++ b/ui/src/app/service-worker-cache.test.ts @@ -285,6 +285,7 @@ describe("Control UI service worker cache versioning", () => { listener: (event: { data: unknown; ports: unknown[] }) => void, ) => listeners.set(type, listener), location: { href: "https://control.example/sw.js?v=old-build" }, + registration: { scope: "https://control.example/" }, }, }); listeners.get("message")?.({ diff --git a/ui/src/e2e/browser-auth-recovery.e2e.test.ts b/ui/src/e2e/browser-auth-recovery.e2e.test.ts index 5c772dcaefe4..d65132051ad3 100644 --- a/ui/src/e2e/browser-auth-recovery.e2e.test.ts +++ b/ui/src/e2e/browser-auth-recovery.e2e.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { Route } from "playwright"; import { expect, it } from "vitest"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-bootstrap-contract.js"; +import { createDeferred } from "../../../test/helpers/promise.js"; import { controlUiSessionUrl, createControlUiMockSameOriginGatewayScript, @@ -27,14 +28,8 @@ suite.define(() => { let metadataRequests = 0; let outgoingRequests = 0; let probes = 0; - let releaseMedia!: () => void; - let releaseProbe!: () => void; - const mediaGate = new Promise((resolve) => { - releaseMedia = resolve; - }); - const probeGate = new Promise((resolve) => { - releaseProbe = resolve; - }); + const mediaGate = createDeferred(); + const probeGate = createDeferred(); const image = Buffer.from( await page.evaluate(() => { const canvas = document.createElement("canvas"); @@ -97,7 +92,7 @@ suite.define(() => { return; } probes += 1; - await probeGate; + await probeGate.promise; await route.fulfill( expired ? { status: 302, headers: { location: "https://sign-in.example.test/login" } } @@ -116,7 +111,7 @@ suite.define(() => { ); await context.route("**/api/chat/media/outgoing/**", async (route) => { outgoingRequests += 1; - await mediaGate; + await mediaGate.promise; await route.fulfill( expired ? { status: 302, headers: { location: "https://sign-in.example.test/login" } } @@ -128,7 +123,7 @@ suite.define(() => { if (metadata) { metadataRequests += 1; } - await mediaGate; + await mediaGate.promise; if (expired) { await route.fulfill({ status: 302, @@ -173,13 +168,13 @@ suite.define(() => { const originalUrl = page.url(); const initialConnects = (await gateway.getRequests("connect")).length; expect(initialConnects).toBe(1); - releaseMedia(); + mediaGate.resolve(); await page.getByRole("button", { name: "Retry", exact: true }).first().waitFor(); await page.screenshot({ animations: "disabled", path: path.join(artifactDir, "01-image-unavailable.png"), }); - releaseProbe(); + probeGate.resolve(); const modal = page .locator("openclaw-modal-dialog") .filter({ hasText: "Sign in to continue loading content" }); diff --git a/ui/src/pages/chat/components/chat-media-source.ts b/ui/src/pages/chat/components/chat-media-source.ts index a42238d16982..d4ee1bf11836 100644 --- a/ui/src/pages/chat/components/chat-media-source.ts +++ b/ui/src/pages/chat/components/chat-media-source.ts @@ -168,17 +168,13 @@ export class ChatMediaSourceController { } handleError(media: HTMLMediaElement): boolean { - if (!this.pendingSource) { - this.sourceFailed = true; - this.playbackSource = ""; - this.playbackReadiness = "unavailable"; - return false; + if (this.applyPendingSource(media)) { + return true; } - this.applySource(media, this.pendingSource, this.pendingIdentity, { - currentTime: finiteMediaTime(media.currentTime), - paused: media.paused, - }); - return true; + this.sourceFailed = true; + this.playbackSource = ""; + this.playbackReadiness = "unavailable"; + return false; } applyPendingSource(media: HTMLMediaElement): boolean { diff --git a/ui/src/pages/chat/components/chat-message-attachment-availability.ts b/ui/src/pages/chat/components/chat-message-attachment-availability.ts index 19b89e96e80a..68db62627767 100644 --- a/ui/src/pages/chat/components/chat-message-attachment-availability.ts +++ b/ui/src/pages/chat/components/chat-message-attachment-availability.ts @@ -6,6 +6,7 @@ import { isLocalAssistantAttachmentSource, } from "./chat-message-local-media.ts"; import { + clearChatMediaResourceRefresh, isChatMediaResourceCurrent, notifyChatMediaResourceSubscribers, observeChatMediaResource, @@ -259,7 +260,7 @@ function resetAssistantAttachmentAvailability( resource.value = undefined; resource.retainUntil = undefined; resource.retryAttempted = false; - scheduleAssistantAttachmentRefresh(resource, { status: "checking" }); + clearChatMediaResourceRefresh(resource); } function createUnavailableAssistantAttachment( diff --git a/ui/src/pages/chat/components/chat-message-images.ts b/ui/src/pages/chat/components/chat-message-images.ts index dc86f0ac9065..67190161587f 100644 --- a/ui/src/pages/chat/components/chat-message-images.ts +++ b/ui/src/pages/chat/components/chat-message-images.ts @@ -27,6 +27,7 @@ import { } from "./chat-message-local-media.ts"; import { cacheManagedImageBlob, + clearChatMediaResourceRefresh, isChatMediaResourceCurrent, notifyChatMediaResourceSubscribers, observeChatMediaResource, @@ -67,7 +68,11 @@ class MessageImageResourceDirective extends AsyncDirective { private presentationKey = Symbol("image-presentation"); private retained: RetainedInlineImage | { status: "unavailable" } | undefined; // Resource updates stay in this part; row ResizeObserver owns layout changes. - private readonly requestUpdate = () => this.refreshImage(); + private readonly refreshImage = () => { + if (this.isConnected && this.image) { + this.setValue(this.render(this.image, this.options)); + } + }; private readonly onSettled = (event: Event, source: string) => { // A removed IMG may finish after denial; it no longer owns displayed pixels. const element = event.currentTarget; @@ -119,13 +124,13 @@ class MessageImageResourceDirective extends AsyncDirective { this.element = undefined; this.presentationKey = Symbol("image-presentation"); } - releaseChatMediaResourceSubscriber(this.requestUpdate); + releaseChatMediaResourceSubscriber(this.refreshImage); } this.image = image; this.options = options; if (!this.isConnected) { this.releaseRetainedImage(); - releaseChatMediaResourceSubscriber(this.requestUpdate); + releaseChatMediaResourceSubscriber(this.refreshImage); return noChange; } const onRequestUpdate = options?.onRequestUpdate; @@ -134,12 +139,12 @@ class MessageImageResourceDirective extends AsyncDirective { // callback changes without discarding its loaded resource. if (onRequestUpdate) { this.pendingPreview = undefined; - observeChatMediaResourceSubscriber(onRequestUpdate, this.requestUpdate); + observeChatMediaResourceSubscriber(onRequestUpdate, this.refreshImage); } else { - releaseChatMediaResourceSubscriber(this.requestUpdate); + releaseChatMediaResourceSubscriber(this.refreshImage); } const subscriptionOptions = onRequestUpdate - ? { ...options, onRequestUpdate: this.requestUpdate } + ? { ...options, onRequestUpdate: this.refreshImage } : options; const availability = resolveAssistantAttachmentAvailability(image.url, subscriptionOptions); const decodeFailed = this.retained?.status === "unavailable"; @@ -330,7 +335,7 @@ class MessageImageResourceDirective extends AsyncDirective { resolveManagedOutgoingImageResource( image.url, this.options?.onRequestUpdate - ? { ...this.options, onRequestUpdate: this.requestUpdate } + ? { ...this.options, onRequestUpdate: this.refreshImage } : this.options, image.artifactId, "thumbnail", @@ -361,12 +366,6 @@ class MessageImageResourceDirective extends AsyncDirective { this.refreshImage(); } - private refreshImage() { - if (this.isConnected && this.image) { - this.setValue(this.render(this.image, this.options)); - } - } - private present(value: unknown) { return html`${keyed(this.presentationKey, value)}`; } @@ -376,7 +375,7 @@ class MessageImageResourceDirective extends AsyncDirective { this.element = undefined; this.pendingPreview = undefined; this.presentationKey = Symbol("image-presentation"); - releaseChatMediaResourceSubscriber(this.requestUpdate); + releaseChatMediaResourceSubscriber(this.refreshImage); } protected override reconnected() { @@ -547,9 +546,7 @@ function resolveManagedOutgoingImageResource( resource.value = undefined; resource.retryAttempted = false; resource.unavailableAt = undefined; - scheduleChatMediaResourceRefresh(resource, undefined, () => - notifyChatMediaResourceSubscribers(resource), - ); + clearChatMediaResourceRefresh(resource); notifyChatMediaResourceSubscribers(resource); }); } @@ -640,7 +637,7 @@ async function fetchManagedOutgoingImageBlob( opts: ImageRenderOptions | undefined, artifactId: string | undefined, variant: ManagedImageVariant, - controller = new AbortController(), + controller: AbortController, ): Promise { const requesterSessionKey = resolveManagedOutgoingMediaSessionKey(source); const artifactDownload = diff --git a/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts b/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts index 9c0133745137..d28fcd70402a 100644 --- a/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts +++ b/ui/src/pages/chat/components/chat-message-media-lifecycle.test.ts @@ -996,7 +996,12 @@ describe("chat media resource lifecycle", () => { const { blobUrl } = installManagedImageUrls(); const resolveArtifactDownload = vi.fn(async () => ({ url: ticketedUrl })); const fetchMock = vi - .fn() + .fn< + ( + url: string, + init: RequestInit, + ) => Promise<{ ok: false } | ReturnType> + >() .mockResolvedValueOnce({ ok: false }) .mockResolvedValueOnce(imageResponse()); vi.stubGlobal("fetch", fetchMock); @@ -1013,7 +1018,7 @@ describe("chat media resource lifecycle", () => { expect(resolveArtifactDownload).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2); - for (const [requestUrl, init] of fetchMock.mock.calls as Array<[string, RequestInit]>) { + for (const [requestUrl, init] of fetchMock.mock.calls) { expect(requestUrl).toBe(ticketedUrl.replace(/\/full(?=\?)/u, "/thumbnail")); const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBeNull(); diff --git a/ui/src/pages/chat/components/chat-message-media.ts b/ui/src/pages/chat/components/chat-message-media.ts index e70bc130c2bb..c7c116b4c11c 100644 --- a/ui/src/pages/chat/components/chat-message-media.ts +++ b/ui/src/pages/chat/components/chat-message-media.ts @@ -144,10 +144,7 @@ function detachChatMediaResourceSubscriber( } resource.releaseAuthRecovery?.(); resource.releaseAuthRecovery = undefined; - if (resource.refresh) { - clearTimeout(resource.refresh.timer); - resource.refresh = undefined; - } + clearChatMediaResourceRefresh(resource); const resourceKey = chatMediaResourceKey(resource.kind, resource.cacheKey); if (chatMediaResources.get(resourceKey) === resource) { chatMediaResources.delete(resourceKey); @@ -184,9 +181,7 @@ export function observeChatMediaResource( ) { chatMediaResources.delete(resourceKey); resource.abortController?.abort(); - if (resource.refresh) { - clearTimeout(resource.refresh.timer); - } + clearChatMediaResourceRefresh(resource); resource = undefined; } if (!resource) { @@ -204,7 +199,7 @@ export function observeChatMediaResource( refresh: undefined, retainUntil: undefined, }; - chatMediaResources.set(resourceKey, resource as ChatMediaResource); + chatMediaResources.set(resourceKey, resource); } const newObservation = !subscriber || !resource.subscribers.has(subscriber); if (subscriber) { @@ -216,7 +211,7 @@ export function observeChatMediaResource( if (previous && previous !== resource) { detachChatMediaResourceSubscriber(previous, subscriber); } - subscriptions.set(subscriptionKey, resource as ChatMediaResource); + subscriptions.set(subscriptionKey, resource); } if (cacheScope !== undefined && newObservation) { // Policy changes can replace the directive. Let active readers finish, but @@ -266,6 +261,13 @@ export function notifyChatMediaResourceSubscribers(resource: ChatMediaRes } } +export function clearChatMediaResourceRefresh(resource: ChatMediaResource) { + if (resource.refresh) { + clearTimeout(resource.refresh.timer); + resource.refresh = undefined; + } +} + export function scheduleChatMediaResourceRefresh( resource: ChatMediaResource, refreshAt: number | undefined, @@ -274,10 +276,7 @@ export function scheduleChatMediaResourceRefresh( if (resource.refresh?.at === refreshAt) { return; } - if (resource.refresh) { - clearTimeout(resource.refresh.timer); - resource.refresh = undefined; - } + clearChatMediaResourceRefresh(resource); if (refreshAt === undefined || resource.subscribers.size === 0) { return; }