mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor(ui): simplify browser access and media recovery (#148811)
This commit is contained in:
parent
5cf5317dd2
commit
3b5258365c
11 changed files with 134 additions and 171 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Response>();
|
||||
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<Response>((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<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === "HEAD") {
|
||||
return new Promise<Response>((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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")?.({
|
||||
|
|
|
|||
|
|
@ -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<void>((resolve) => {
|
||||
releaseMedia = resolve;
|
||||
});
|
||||
const probeGate = new Promise<void>((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" });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<Blob | null> {
|
||||
const requesterSessionKey = resolveManagedOutgoingMediaSessionKey(source);
|
||||
const artifactDownload =
|
||||
|
|
|
|||
|
|
@ -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<typeof imageResponse>>
|
||||
>()
|
||||
.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();
|
||||
|
|
|
|||
|
|
@ -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<Value>(
|
|||
) {
|
||||
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<Value>(
|
|||
refresh: undefined,
|
||||
retainUntil: undefined,
|
||||
};
|
||||
chatMediaResources.set(resourceKey, resource as ChatMediaResource<unknown>);
|
||||
chatMediaResources.set(resourceKey, resource);
|
||||
}
|
||||
const newObservation = !subscriber || !resource.subscribers.has(subscriber);
|
||||
if (subscriber) {
|
||||
|
|
@ -216,7 +211,7 @@ export function observeChatMediaResource<Value>(
|
|||
if (previous && previous !== resource) {
|
||||
detachChatMediaResourceSubscriber(previous, subscriber);
|
||||
}
|
||||
subscriptions.set(subscriptionKey, resource as ChatMediaResource<unknown>);
|
||||
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<Value>(resource: ChatMediaRes
|
|||
}
|
||||
}
|
||||
|
||||
export function clearChatMediaResourceRefresh(resource: ChatMediaResource<unknown>) {
|
||||
if (resource.refresh) {
|
||||
clearTimeout(resource.refresh.timer);
|
||||
resource.refresh = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleChatMediaResourceRefresh<Value>(
|
||||
resource: ChatMediaResource<Value>,
|
||||
refreshAt: number | undefined,
|
||||
|
|
@ -274,10 +276,7 @@ export function scheduleChatMediaResourceRefresh<Value>(
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue