feat(02-07): stable AIUI embed URL carrying both D-14 defaults
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m16s

aiuiUrl now appends chatExpanded=true and mobileChat=true alongside
the pre-existing embedded=true and hideClose=true. Both are static
strings with no reactive dependency, so the computed's value never
changes after first evaluation — preserving the URL-stability
contract 02-04 relies on to keep the AIUI iframe from reloading on a
tab switch.

AIUI reads these two flags (commit 900c0b9 in the AIUI checkout,
recorded in 02-AIUI-D14.md) to start the chat expanded and, on
mobile, on the chat view rather than the context view. The deployed
AIUI build on any node does not yet carry that commit (anonymous push
to the AIUI remote was rejected — see 02-AIUI-D14.md's Deployment
Impact), so neode-ui's two new query params are inert no-ops against
today's deployed AIUI until that commit is merged and shipped; both
flags are additive and harmless in the meantime.

New test file chatAiuiEmbed.test.ts covers: both D-14 flags plus the
pre-existing embedded/hideClose params present in the URL; URL
string-equality across a simulated viewport resize and across a
KeepAlive deactivate/reactivate cycle; onAiuiMessage still rejecting
a foreign-origin message; and aiuiConnected surviving a
deactivate/reactivate cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 18:26:52 -04:00
parent 8d2ce96eee
commit e2b2ade3b2
2 changed files with 159 additions and 2 deletions

View File

@ -71,13 +71,25 @@ const aiuiFrame = ref<HTMLIFrameElement | null>(null)
const aiuiConnected = ref(false)
let broker: ContextBroker | null = null
// D-14 presentation flags (Phase 02 Plan 07, 02-AIUI-D14.md): AIUI opens
// already expanded (chatExpanded) and, on a mobile viewport, on its chat view
// rather than its context view (mobileChat). Both are static strings never
// derived from a reactive viewport read, connection state, or timestamp so
// the computed below has no reactive dependencies and its value never
// changes after first evaluation, which is exactly what keeps the iframe
// `src` byte-identical across re-renders, resizes, and deactivate/reactivate
// cycles (the load-bearing constraint that lets the AIUI panel survive a tab
// switch without reloading). AIUI decides how to apply mobileChat against its
// own viewport rather than neode-ui's, per 02-AIUI-D14.md's D-14b rationale.
const D14_FLAGS = 'chatExpanded=true&mobileChat=true'
const aiuiUrl = computed(() => {
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
if (envUrl) return `${envUrl}?embedded=true&hideClose=true&${D14_FLAGS}${demo}`
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true&${D14_FLAGS}${demo}`
return ''
})

View File

@ -0,0 +1,145 @@
// 02-07: the AIUI embed URL must be byte-stable across re-renders, a
// simulated viewport resize, and a KeepAlive deactivate/reactivate cycle —
// any runtime-varying input would change the iframe `src` and force a full
// AIUI reload on the next tab switch, giving back the entire benefit 02-04
// established for the Chat tab. Also covers the two D-14 presentation
// flags (chatExpanded, mobileChat), origin validation being unchanged, and
// aiuiConnected surviving deactivation (AIUI's 'ready' message is not
// re-sent on re-entry).
import { flushPromises, mount } from '@vue/test-utils'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue'
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
// IS_DEMO is a build-time constant in the real module; mock it so these
// tests exercise the plain VITE_AIUI_URL branch deterministically.
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false }))
// ContextBroker pulls in several Pinia stores (app/container/aiPermissions)
// unrelated to this test's concern (URL stability + origin validation) —
// mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's
// convention for isolating a view from its heavier dependencies.
vi.mock('@/services/contextBroker', () => ({
ContextBroker: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}))
/** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */
function mountChatInKeepAlive() {
const show = ref(true)
const Host = defineComponent({
setup() {
return () => h(KeepAlive, null, {
default: () => (show.value ? h(Chat) : h('div', 'other-tab')),
})
},
})
const wrapper = mount(Host)
return { wrapper, show }
}
function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
return wrapper.find('iframe').attributes('src')
}
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
beforeEach(() => {
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
const { wrapper } = mountChatInKeepAlive()
const src = iframeSrc(wrapper)
expect(src).toBeTruthy()
expect(src).toContain('embedded=true')
expect(src).toContain('hideClose=true')
expect(src).toContain('chatExpanded=true')
expect(src).toContain('mobileChat=true')
wrapper.unmount()
})
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 })
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('is string-equal before and after a deactivate/reactivate cycle', async () => {
const { wrapper, show } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('does not set aiuiConnected for a message from a foreign origin', async () => {
const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://evil.example',
}))
await flushPromises()
// aiuiConnected stays false: the loading overlay is still shown and the
// connected indicator (title="chat.aiuiConnected") is absent.
expect(wrapper.find('.chat-loading').exists()).toBe(true)
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
})
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://localhost:5173',
}))
await flushPromises()
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
await flushPromises()
// No second 'ready' message is sent on reactivation — aiuiConnected must
// not have been reset to false by the deactivate/reactivate cycle.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
wrapper.unmount()
})
})