From e2b2ade3b2f28648d7361fc3c3bfaaf063882cc0 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 30 Jul 2026 18:26:52 -0400 Subject: [PATCH] feat(02-07): stable AIUI embed URL carrying both D-14 defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- neode-ui/src/views/Chat.vue | 16 +- .../src/views/__tests__/chatAiuiEmbed.test.ts | 145 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index d59edcdd..9154ccab 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -71,13 +71,25 @@ const aiuiFrame = ref(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 '' }) diff --git a/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts new file mode 100644 index 00000000..ff2e4efd --- /dev/null +++ b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts @@ -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 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): 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() + }) +})