feat(spotlight): offer 'Talk to AIUI about it' for the typed query

Cmd/Ctrl+K search could only match text against known screens; anything it
did not recognise dead-ended at 'No results'. That text is now handed to the
assistant instead: a blue accented row (chat-bubble + sparkle) appears while
there is a query, always last in the keyboard order, so Cmd+K -> type -> Enter
reaches AIUI without the mouse. On a zero-match query it is the only option.

The prompt travels by postMessage, NOT as an iframe URL param. Chat.vue's
aiuiUrl is deliberately free of reactive dependencies so the iframe src stays
byte-identical and AIUI survives a tab switch (see the D14_FLAGS comment);
threading the question through the URL would reload AIUI and discard the
conversation on every ask — the opposite of the intent. Two regression tests
pin this: the src is byte-identical across an ask, and ask/askedAt are
stripped afterwards so a refresh cannot silently re-ask.

The ask is queued and flushed on AIUI's 'ready' handshake, because arriving
from Cmd+K on a cold Chat tab means the iframe has not connected yet.

AIUI-side receiver lands separately; until then this posts a message AIUI
ignores, which is inert rather than broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 16:45:33 -04:00
co-authored by Claude Opus 5
parent 3015a8acf3
commit 97b3707b5e
3 changed files with 156 additions and 5 deletions
@@ -13,9 +13,16 @@ import Chat from '../Chat.vue'
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
const routerReplaceMock = vi.fn()
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
// handoff, and route.path when it strips those params back off. Kept empty by
// default so the byte-stability assertions below see no ask in play.
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock }),
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
useRoute: () => routeMock,
}))
vi.mock('vue-i18n', () => ({
@@ -62,6 +69,8 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
afterEach(() => {
vi.unstubAllEnvs()
routeMock.query = {}
routerReplaceMock.mockClear()
})
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
@@ -75,6 +84,56 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
wrapper.unmount()
})
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
// by postMessage: putting it in the URL would give aiuiUrl a reactive
// dependency and reload AIUI on every question, which is precisely the
// byte-stability property the rest of this file exists to protect.
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
expect(before).not.toContain('ask=')
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).toHaveBeenCalledWith(
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
'http://localhost:5173',
)
// src must be byte-identical after the ask round-trip
expect(iframeSrc(wrapper)).toBe(before)
// and the params are stripped so a refresh does not silently re-ask
expect(routerReplaceMock).toHaveBeenCalled()
const replaceArg = routerReplaceMock.mock.calls[0][0]
expect(replaceArg.query.ask).toBeUndefined()
expect(replaceArg.query.askedAt).toBeUndefined()
wrapper.unmount()
})
it('does not post a prefill when there is no ask in the route', async () => {
const { wrapper } = mountChatInKeepAlive()
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).not.toHaveBeenCalled()
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)