diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfd26c285d..88d172fd597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- **Control UI update recovery:** the "A new version is available" Reload button now waits out the gateway restart that stranded the chunk and reloads as soon as it answers, instead of silently doing nothing and leaving a manual hard reload as the only way out. - **Control UI sender identity polish:** attributed user messages show the author's real avatar in an always-visible gutter on identity-resolving gateways, sender labels drop the opaque profile-UUID suffix (new and historical transcripts), and profile-id senders resolve avatars through the canonical gateway route. - **Control UI who's-online roster:** click the sidebar footer facepile to open a scrollable roster of everyone online, showing each person's avatar, name, and email with your own entry pinned first. - **Discord and Slack native login:** register `/login` in native command menus while keeping pairing-code issuance limited to private chats and the Web UI. diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts index 8bcf0831f05..4b50865697e 100644 --- a/ui/src/app/router-outlet.test.ts +++ b/ui/src/app/router-outlet.test.ts @@ -172,7 +172,7 @@ describe("openclaw-router-outlet", () => { router.stop(); }); - it("schedules stale-chunk recovery and falls back to revalidation while offline", async () => { + it("waits out a restarting gateway before falling back to revalidation", async () => { vi.useFakeTimers(); let loadCount = 0; const fetchMock = vi.fn( @@ -214,15 +214,27 @@ describe("openclaw-router-outlet", () => { expect(alert?.textContent).toContain("Reload to get the latest panel"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(loadCount).toBe(1); - outlet.querySelector("button")?.click(); + const button = outlet.querySelector("button"); + button?.click(); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledTimes(1); + + // The gateway restart is what stranded the chunk, so one failed probe must + // not end the retry: the click keeps waiting (and shows it) rather than + // silently degrading to a revalidation that cannot fix a replaced chunk. await vi.advanceTimersByTimeAsync(3_000); vi.runAllTicks(); await settleOutlet(outlet); - expect(loadCount).toBe(2); + expect(loadCount).toBe(1); + expect(button?.disabled).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); + // Past the bounded wait it still degrades to revalidation instead of + // navigating into a fatal error page against an unreachable gateway. + await vi.advanceTimersByTimeAsync(35_000); + vi.runAllTicks(); + await settleOutlet(outlet); + expect(loadCount).toBe(2); + expect(fetchMock.mock.calls.length).toBeGreaterThan(1); outlet.remove(); router.stop(); }); diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts index baad6a22551..257edf7cc7e 100644 --- a/ui/src/app/router-outlet.ts +++ b/ui/src/app/router-outlet.ts @@ -13,7 +13,7 @@ import { } from "./router-outlet-controller.ts"; import { isStaleChunkImportError, - retryStaleChunkReload, + retryStaleChunkReloadWhenReachable, scheduleStaleChunkReload, } from "./stale-chunk-reload.ts"; @@ -55,6 +55,25 @@ function renderPending() { `; } +/** + * Shows progress while waiting for the restarting gateway. The state lives on + * the element rather than in render state because the reload replaces the + * document; a re-render that resets the label is harmless, since the pending + * wait still reloads on its own once the gateway answers. + */ +function markButtonReloading(button: HTMLButtonElement | null): () => void { + if (!button) { + return () => {}; + } + const label = button.textContent; + button.disabled = true; + button.textContent = t("lazyView.reloading"); + return () => { + button.disabled = false; + button.textContent = label; + }; +} + function renderError( router: Router, retryContext: TLoadContext | undefined, @@ -75,17 +94,25 @@ function renderError( } void router.revalidate(retryContext, routeId).catch(() => undefined); }; - const handleRetry = () => { + const handleRetry = (event: Event) => { if (!staleChunk) { revalidate(); return; } - // Reload only when the gateway is reachable; during a restart fall back to - // revalidation so the panel error stays recoverable inside app webviews. - void retryStaleChunkReload().then((reloading) => { - if (!reloading) { - revalidate(); + // The gateway is usually still restarting when this is clicked (that update + // is what stranded the chunk), so wait for it to answer and then reload + // instead of declining on the first failed probe — a silent no-op here is + // what drives people to a manual hard reload. Reloading against an + // unreachable gateway would replace the recoverable panel error with a + // fatal navigation error in app webviews, so the wait is still bounded. + const button = event.currentTarget instanceof HTMLButtonElement ? event.currentTarget : null; + const restoreButton = markButtonReloading(button); + void retryStaleChunkReloadWhenReachable().then((reloading) => { + if (reloading) { + return; } + restoreButton(); + revalidate(); }); }; // Stale-chunk failures are routine after a gateway update, so present them diff --git a/ui/src/app/stale-chunk-reload.test.ts b/ui/src/app/stale-chunk-reload.test.ts index 94606d6a256..254d2deddd0 100644 --- a/ui/src/app/stale-chunk-reload.test.ts +++ b/ui/src/app/stale-chunk-reload.test.ts @@ -3,7 +3,7 @@ import { installMissingStylesheetRecovery, installStaleChunkReloadListener, isStaleChunkImportError, - retryStaleChunkReload, + retryStaleChunkReloadWhenReachable, scheduleStaleChunkReload, } from "./stale-chunk-reload.ts"; @@ -196,7 +196,7 @@ describe("scheduleStaleChunkReload", () => { vi.useFakeTimers(); const reload = vi.fn(); const fetchMock = stubHangingDocumentFetch(); - const retry = retryStaleChunkReload({ reload }); + const retry = retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 }); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -219,30 +219,73 @@ describe("scheduleStaleChunkReload", () => { const storage = memoryStorage(); const automatic = scheduleStaleChunkReload({ now: () => 1000, storage, reload }); - const manual = retryStaleChunkReload({ reload }); + const manual = retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 }); await Promise.resolve(); expect(fetchMock).toHaveBeenCalledTimes(1); firstProbe.resolve(new Response(null, { status: 503 })); await expect(Promise.all([automatic, manual])).resolves.toEqual([false, false]); - await expect(retryStaleChunkReload({ reload })).resolves.toBe(true); + await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(true); expect(fetchMock).toHaveBeenCalledTimes(2); expect(reload).toHaveBeenCalledTimes(1); }); }); -describe("retryStaleChunkReload", () => { +describe("retryStaleChunkReloadWhenReachable single-shot", () => { it("reloads without the rate guard when the gateway is reachable", async () => { const reload = vi.fn(); stubDocumentFetch(new Response(null, { status: 200 })); - await expect(retryStaleChunkReload({ reload })).resolves.toBe(true); + await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(true); expect(reload).toHaveBeenCalledTimes(1); }); it("does not reload while the gateway is unreachable", async () => { const reload = vi.fn(); stubDocumentFetch(new Response(null, { status: 503 })); - await expect(retryStaleChunkReload({ reload })).resolves.toBe(false); + await expect(retryStaleChunkReloadWhenReachable({ reload, timeoutMs: 0 })).resolves.toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("retryStaleChunkReloadWhenReachable", () => { + it("reloads immediately when the gateway already answers", async () => { + const reload = vi.fn(); + const probe = vi.fn().mockResolvedValue(true); + await expect(retryStaleChunkReloadWhenReachable({ reload, probe })).resolves.toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("waits out a restarting gateway and then reloads", async () => { + // The stale chunk exists because the gateway just restarted, so the first + // probes legitimately fail; declining here is what stranded the user. + const reload = vi.fn(); + const probe = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + const wait = vi.fn().mockResolvedValue(undefined); + await expect( + retryStaleChunkReloadWhenReachable({ reload, probe, wait, intervalMs: 5 }), + ).resolves.toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + expect(probe).toHaveBeenCalledTimes(3); + expect(wait).toHaveBeenCalledTimes(2); + }); + + it("gives up at the deadline without navigating into an error page", async () => { + const reload = vi.fn(); + const probe = vi.fn().mockResolvedValue(false); + const wait = vi.fn().mockResolvedValue(undefined); + let clock = 0; + const now = () => { + clock += 400; + return clock; + }; + await expect( + retryStaleChunkReloadWhenReachable({ reload, probe, wait, now, timeoutMs: 1_000 }), + ).resolves.toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); @@ -401,3 +444,52 @@ describe("installMissingStylesheetRecovery", () => { } }); }); + +describe("retryStaleChunkReloadWhenReachable deadline enforcement", () => { + it("resolves at the deadline even when the probe never settles", async () => { + vi.useFakeTimers(); + try { + const reload = vi.fn(); + // A caller-supplied probe need not time out itself; the bound must still + // hold or the pending UI would be stranded forever. + const probe = vi.fn(() => new Promise(() => {})); + const pending = retryStaleChunkReloadWhenReachable({ + reload, + probe, + timeoutMs: 5_000, + }); + await vi.advanceTimersByTimeAsync(6_000); + await expect(pending).resolves.toBe(false); + expect(reload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +it("never starts an unbounded probe once the wait carried past the deadline", async () => { + const reload = vi.fn(); + let calls = 0; + // A second probe would hang forever; the loop must not start one, or the + // caller's disabled Reload button would be stranded past its own bound. + const probe = vi.fn(async () => { + calls += 1; + return calls === 1 ? false : new Promise(() => {}); + }); + let clock = 0; + const wait = vi.fn(async () => { + clock += 10_000; + }); + + await expect( + retryStaleChunkReloadWhenReachable({ + reload, + probe, + wait, + now: () => clock, + timeoutMs: 5_000, + }), + ).resolves.toBe(false); + expect(probe).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); +}); diff --git a/ui/src/app/stale-chunk-reload.ts b/ui/src/app/stale-chunk-reload.ts index 1f0678c5811..d2ef89fdc49 100644 --- a/ui/src/app/stale-chunk-reload.ts +++ b/ui/src/app/stale-chunk-reload.ts @@ -154,17 +154,75 @@ export async function scheduleStaleChunkReload(deps: StaleChunkReloadDeps = {}): return true; } +// A restarting gateway is the common case behind this banner: the stale chunk +// exists precisely because the gateway was just updated. Give the restart time +// to finish rather than declining the reload on the first failed probe. +const REACHABLE_WAIT_TIMEOUT_MS = 30_000; +const REACHABLE_WAIT_INTERVAL_MS = 1_000; + /** - * User-initiated retry: bypasses the automatic-reload rate guard but keeps the - * reachability probe — reloading against an unreachable gateway replaces the - * recoverable panel error with a fatal navigation error in app webviews. + * Keeps the advertised bound local instead of trusting the probe to time out: + * the default probe aborts itself, but a caller-supplied one need not, and a + * probe that never settles would strand the caller's pending UI forever. */ -export async function retryStaleChunkReload(deps: StaleChunkReloadDeps = {}): Promise { - if (!(await probeControlUiDocument())) { - return false; +async function probeWithinDeadline( + probe: () => Promise, + remainingMs: number, +): Promise { + let timer: ReturnType | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }); + try { + return await Promise.race([probe(), expired]); + } finally { + clearTimeout(timer); + } +} + +/** + * User-initiated retry that survives the restart which caused the stale chunk: + * poll until the gateway answers, then reload. Returns false only when it stays + * unreachable for the whole window, so callers keep the recoverable panel error + * instead of navigating into a fatal error page. + */ +export async function retryStaleChunkReloadWhenReachable( + deps: StaleChunkReloadDeps & { + timeoutMs?: number; + intervalMs?: number; + probe?: () => Promise; + wait?: (ms: number) => Promise; + } = {}, +): Promise { + const now = deps.now ?? Date.now; + const probe = deps.probe ?? probeControlUiDocument; + const wait = + deps.wait ?? + ((ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + })); + const intervalMs = deps.intervalMs ?? REACHABLE_WAIT_INTERVAL_MS; + const deadline = now() + (deps.timeoutMs ?? REACHABLE_WAIT_TIMEOUT_MS); + for (let attempt = 0; ; attempt += 1) { + const remaining = deadline - now(); + // The interval wait can carry the loop past the deadline, so re-check here: + // only the first attempt may probe from outside the window. + if (attempt > 0 && remaining <= 0) { + return false; + } + // The first attempt always probes, so timeoutMs: 0 means "single shot" + // rather than "never ask"; the probe's own abort bounds that case. + const reachable = remaining > 0 ? await probeWithinDeadline(probe, remaining) : await probe(); + if (reachable) { + (deps.reload ?? reloadControlUiDocument)(); + return true; + } + if (now() >= deadline) { + return false; + } + await wait(intervalMs); } - (deps.reload ?? reloadControlUiDocument)(); - return true; } /** @@ -195,7 +253,9 @@ export function installMissingStylesheetRecovery( getComputedStyle(document.documentElement).getPropertyValue("--openclaw-css-ok").trim() === "1"); const schedule = deps.schedule ?? scheduleStaleChunkReload; - const retry = deps.retry ?? retryStaleChunkReload; + // Single-shot (timeoutMs: 0) keeps the stylesheet banner's existing + // behavior; only the lazy-route button waits out a restart. + const retry = deps.retry ?? (() => retryStaleChunkReloadWhenReachable({ timeoutMs: 0 })); let detected = false; let uninstalled = false; let banner: HTMLDivElement | null = null; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 0609ea49326..33003bf44b6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -280,6 +280,7 @@ export const en: TranslationMap = { genericSubtitle: "Something went wrong while loading this panel.", staleTitle: "A new version is available", staleSubtitle: "OpenClaw was updated in the background. Reload to get the latest panel.", + reloading: "Reloading…", retry: "Retry", }, nodes: {