fix(02-07): loading overlay can never wedge the Chat/AIUI UI permanently
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m12s

Belt-and-suspenders fix on top of AIUI's own root-cause fix (the
archyBridge origin bug, fixed in the AIUI repo): the archy-side loading
overlay now gets pointer-events:none (it has no interactive content of
its own, so it should never have blocked clicks reaching the iframe
underneath) and a bounded 8s timeout that unconditionally hides it if
no 'ready' message ever arrives — regardless of AIUI/backend state.
The timeout only dismisses the overlay; it does not fabricate a
successful connection, so the connected indicator still reflects
reality.

Two new tests in chatAiuiEmbed.test.ts cover the timeout firing at
exactly 8s and not firing prematurely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 20:19:28 -04:00
parent 08ee5ed036
commit faf4a75ddf
2 changed files with 71 additions and 2 deletions

View File

@ -15,9 +15,14 @@
/>
</div>
<!-- Loading indicator while iframe loads -->
<!-- Loading indicator while iframe loads. pointer-events:none on the
wrapper (see <style>) so this never blocks clicks reaching the
iframe underneath even while shown; the bounded timeout below
(see aiuiLoadTimedOut) additionally guarantees it disappears
outright regardless of backend/handshake state, so it can never
wedge the UI permanently. -->
<Transition name="fade">
<div v-if="aiuiUrl && !aiuiConnected" class="chat-loading" role="status" aria-live="polite">
<div v-if="aiuiUrl && !aiuiConnected && !aiuiLoadTimedOut" class="chat-loading" role="status" aria-live="polite">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="chat-loading-spinner" aria-hidden="true" />
<p class="text-sm text-white/60">{{ t('chat.loadingAssistant') }}</p>
@ -69,6 +74,17 @@ const { t } = useI18n()
const router = useRouter()
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
// loading overlay must never be able to wedge the UI permanently regardless
// of AIUI/backend state a broken handshake, a misconfigured origin, or the
// AI provider being unreachable must not leave the user staring at a
// spinner forever with no way to interact with the panel underneath. This
// timeout dismisses the overlay unconditionally after a bounded wait even if
// 'ready' never arrives; it does not affect aiuiConnected itself (the
// connection indicator dot still reflects the real state).
const AIUI_LOAD_TIMEOUT_MS = 8000
const aiuiLoadTimedOut = ref(false)
let loadTimeout: ReturnType<typeof setTimeout> | null = null
let broker: ContextBroker | null = null
// D-14 presentation flags (Phase 02 Plan 07, 02-AIUI-D14.md): AIUI opens
@ -111,6 +127,7 @@ function onAiuiMessage(event: MessageEvent) {
// Listen for ready messages from AIUI iframe
if (event.data?.type === 'ready') {
aiuiConnected.value = true
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
}
}
@ -131,6 +148,14 @@ function armChatLive() {
broker = new ContextBroker(aiuiFrame, aiuiUrl.value)
broker.start()
}
if (loadTimeout) clearTimeout(loadTimeout)
loadTimeout = null
if (aiuiUrl.value && !aiuiConnected.value) {
loadTimeout = setTimeout(() => {
aiuiLoadTimedOut.value = true
loadTimeout = null
}, AIUI_LOAD_TIMEOUT_MS)
}
}
onActivated(() => armChatLive())
@ -138,6 +163,7 @@ onDeactivated(() => {
window.removeEventListener('message', onAiuiMessage)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
})
// onActivated is a no-op outside a <KeepAlive> boundary call it directly
@ -150,6 +176,7 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onAiuiMessage)
broker?.stop()
broker = null
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
})
</script>
@ -161,6 +188,10 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
z-index: 10;
/* Never let the loading state itself block interaction with the iframe
underneath it has no interactive content of its own, so there is
nothing here that needs to capture clicks. */
pointer-events: none;
}
.chat-loading-spinner {

View File

@ -142,4 +142,42 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
expect(wrapper.find('.chat-loading').exists()).toBe(false)
wrapper.unmount()
})
// Belt-and-suspenders backstop added after live testing found the overlay
// could wedge the UI when the 'ready' handshake never arrives (a real bug,
// separately fixed at its root cause in AIUI's archyBridge.ts) — this
// proves the archy side never depends on that fix alone.
it('dismisses the loading overlay after a bounded timeout even if no ready message ever arrives', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(7999)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
// The connection indicator must NOT falsely report connected — the
// timeout only dismisses the blocking overlay, it does not fabricate
// a successful handshake.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
it('does not dismiss the loading overlay before the timeout elapses', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
await vi.advanceTimersByTimeAsync(4000)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
})