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
+50 -2
View File
@@ -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