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:
co-authored by
Claude Opus 5
parent
3015a8acf3
commit
97b3707b5e
@@ -73,9 +73,40 @@
|
||||
<span class="text-xs text-white/40">{{ item.section }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="p-8 text-center text-white/50">
|
||||
<div v-else class="px-8 pt-8 pb-2 text-center text-white/50">
|
||||
No results for "{{ query }}"
|
||||
</div>
|
||||
|
||||
<!-- Hand the typed text to AIUI. Always offered while there is a
|
||||
query — it is the whole point when nothing matched, and a
|
||||
useful escape hatch when something did. -->
|
||||
<div class="p-2" :class="filteredItems.length > 0 ? 'border-t border-white/10' : ''">
|
||||
<button
|
||||
type="button"
|
||||
class="group w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="getItemClass(askAiuiIndex)"
|
||||
@click="askAiui()"
|
||||
>
|
||||
<span
|
||||
class="relative shrink-0 flex items-center justify-center w-9 h-9 rounded-lg overflow-hidden
|
||||
bg-gradient-to-br from-blue-500/30 via-sky-400/15 to-transparent border border-blue-400/30"
|
||||
>
|
||||
<span class="absolute inset-0 transition-colors group-hover:bg-blue-400/10"></span>
|
||||
<svg
|
||||
class="relative w-[18px] h-[18px] text-blue-300"
|
||||
fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.5 11.5a8 8 0 01-11.9 6.97L4 19.5l1.06-4.3A8 8 0 1120.5 11.5z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12.2 8.4l.78 2.02 2.02.78-2.02.78-.78 2.02-.78-2.02-2.02-.78 2.02-.78z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-white/90">Talk to AIUI about it</span>
|
||||
<span class="block text-xs text-white/40 truncate">“{{ query.trim() }}”</span>
|
||||
</span>
|
||||
<kbd class="hidden sm:inline-flex px-2 py-1 text-xs text-white/50 bg-white/10 rounded shrink-0">↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Help tree when no search -->
|
||||
@@ -157,8 +188,13 @@ const recentOffset = computed(() =>
|
||||
!query.value.trim() && spotlightStore.recentItems.length > 0 ? spotlightStore.recentItems.length : 0
|
||||
)
|
||||
|
||||
// "Talk to AIUI about it" is appended after the matches, so it is the last
|
||||
// selectable row whenever there is a query (including the zero-match case,
|
||||
// where it is the only one).
|
||||
const askAiuiIndex = computed(() => filteredItems.value.length)
|
||||
|
||||
const selectableCount = computed(() => {
|
||||
if (query.value.trim()) return filteredItems.value.length
|
||||
if (query.value.trim()) return filteredItems.value.length + 1
|
||||
return recentOffset.value + allSearchableItems.value.length
|
||||
})
|
||||
|
||||
@@ -247,6 +283,17 @@ function selectItem(item: SearchableItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the raw typed text to AIUI instead of trying to match it to a screen.
|
||||
// The nonce is what makes re-asking the identical question work: without a
|
||||
// changing query the router treats the push as a no-op and Chat.vue never sees
|
||||
// a new `ask` to forward.
|
||||
function askAiui() {
|
||||
const text = query.value.trim()
|
||||
if (!text) return
|
||||
spotlightStore.close()
|
||||
router.push({ path: '/dashboard/chat', query: { ask: text, askedAt: String(Date.now()) } })
|
||||
}
|
||||
|
||||
function selectHelpItem(section: { id: string }, item: { id: string; label: string; path?: string; content?: string; relatedPath?: string }) {
|
||||
const type = section.id === 'navigate' ? 'navigate' : section.id === 'learn' ? 'learn' : 'action'
|
||||
spotlightStore.addRecentItem({
|
||||
@@ -315,6 +362,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
const idx = spotlightStore.selectedIndex
|
||||
if (query.value.trim()) {
|
||||
if (idx === askAiuiIndex.value) { askAiui(); return }
|
||||
const item = filteredItems.value[idx]
|
||||
if (item) selectItem(item)
|
||||
return
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
@@ -72,6 +72,7 @@ import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
|
||||
@@ -109,6 +110,47 @@ const aiuiUrl = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// ⌘K → "Talk to AIUI about it" hands the typed text over as `?ask=`.
|
||||
//
|
||||
// It is delivered by postMessage, NOT by adding a query param to `aiuiUrl`.
|
||||
// That is deliberate: the comment on D14_FLAGS above explains that aiuiUrl must
|
||||
// have no reactive dependencies so the iframe `src` stays byte-identical and
|
||||
// AIUI survives a tab switch. Threading `ask` through the URL would rebuild the
|
||||
// src on every question and reload AIUI, discarding the conversation — the
|
||||
// exact opposite of what this feature is for.
|
||||
//
|
||||
// The ask is queued rather than sent directly, because the common case is
|
||||
// arriving from ⌘K on a cold Chat tab where the iframe has not handshaked yet.
|
||||
// `ready` flushes it.
|
||||
const pendingAsk = ref('')
|
||||
|
||||
function flushAsk() {
|
||||
const text = pendingAsk.value
|
||||
if (!text || !aiuiConnected.value) return
|
||||
const frame = aiuiFrame.value
|
||||
if (!frame?.contentWindow || !aiuiUrl.value) return
|
||||
let targetOrigin: string
|
||||
try {
|
||||
targetOrigin = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
} catch { return }
|
||||
frame.contentWindow.postMessage({ type: 'chat:prefill', text }, targetOrigin)
|
||||
pendingAsk.value = ''
|
||||
// Drop ask/askedAt from the URL so a refresh or a back-nav does not re-ask.
|
||||
const { ask: _a, askedAt: _t, ...rest } = route.query
|
||||
router.replace({ path: route.path, query: rest })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.askedAt,
|
||||
() => {
|
||||
const ask = route.query.ask
|
||||
if (!ask) return
|
||||
pendingAsk.value = String(ask)
|
||||
flushAsk()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeChat() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
@@ -128,6 +170,8 @@ function onAiuiMessage(event: MessageEvent) {
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
// A ⌘K ask that arrived before the handshake is waiting — send it now.
|
||||
flushAsk()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user