Skip to content

Commit 2549f56

Browse files
authored
feat(search): answer intents and repositories in the search box (#440)
* feat(registry): understand search intents and repository refs The search box promises skills or repos, but a repo ref fell through to a semantic search and called its own Skills loose matches, and a sentence was searched word by word. A new internal /api/skills/search classifies the query first: a URL or owner/repo answers with that Repository and its Skills, @login with that owner, and a sentence passes through a small Workers AI model that turns it into search terms and soft boosts before the existing hybrid search. The model is cached per normalised query for a month, abandoned after 800ms, skipped for names, refs and URLs, and gated by the guest rate limiter. The frozen v1 skills.search answer is unchanged. * feat(search): show run commands and repositories in the search box Each Skill row now carries its run chip, and a Repository the registry holds gets its own row, so the box answers both halves of its placeholder. The panel becomes a grid because an option cannot contain a control: arrow right moves to the run chip and Enter copies it. The hero and header boxes share one composable for keys, selection and navigation, so they stop disagreeing about what Enter does. The header field widens while open, rows keep their names readable at phone width, and the preview pane with its empty half is gone. * fix(skills): name the meaning search without AI copy COPY.md bans AI as a modifier on skilld. The search box now sends sentences to this mode, so its label says what the mode does.
1 parent 301a27f commit 2549f56

24 files changed

Lines changed: 2205 additions & 639 deletions

‎app/components/HomeSearch.vue‎

Lines changed: 20 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,28 @@
11
<script setup lang="ts">
2-
import type { SearchRow } from '../composables/useSkillSearch'
32
import { onClickOutside, onKeyStroke } from '@vueuse/core'
43
54
const route = useRoute()
5+
const container = useTemplateRef<HTMLElement>('container')
6+
const input = useTemplateRef<{ inputRef?: HTMLInputElement }>('input')
7+
8+
// Behaviour lives in the shared box composable, so the hero and the header
9+
// search answer, select, and navigate the same way.
610
const {
711
query,
812
trimmedQuery,
913
state,
1014
open,
11-
rows,
12-
activeIndex,
13-
activeRow,
1415
close,
15-
reset,
16-
rememberQuery,
17-
loadTypeaheadIndex,
18-
submitRepository,
19-
} = useSkillSearch()
20-
21-
const container = useTemplateRef<HTMLElement>('container')
22-
const input = useTemplateRef<{ inputRef?: HTMLInputElement }>('input')
23-
const keyboardSelection = ref(false)
24-
25-
function clearSelection(): void {
26-
keyboardSelection.value = false
27-
activeIndex.value = -1
28-
}
29-
30-
watch([trimmedQuery, rows], clearSelection, { flush: 'sync', immediate: true })
31-
watch(open, (visible) => {
32-
if (!visible)
33-
clearSelection()
34-
}, { flush: 'sync' })
35-
onBeforeUnmount(() => {
36-
activeIndex.value = 0
37-
})
38-
39-
function openPanel(): void {
40-
if (!open.value)
41-
clearSelection()
42-
open.value = true
43-
void loadTypeaheadIndex()
44-
}
45-
46-
function dismiss(): void {
47-
close()
48-
input.value?.inputRef?.blur()
49-
}
50-
51-
async function goToResults(): Promise<void> {
52-
const term = trimmedQuery.value
53-
rememberQuery(term)
54-
dismiss()
55-
await navigateTo(term ? { path: '/skills', query: { q: term } } : '/skills')
56-
}
57-
58-
async function select(row: SearchRow): Promise<void> {
59-
if (row._tag === 'repository') {
60-
await submitRepository(row.repository)
61-
return
62-
}
63-
if (row._tag === 'all') {
64-
await goToResults()
65-
return
66-
}
67-
if (state.value._tag !== 'repository')
68-
rememberQuery(trimmedQuery.value)
69-
dismiss()
70-
reset()
71-
await navigateTo(row.skill.registryPath)
72-
}
73-
74-
function submitQuery(): void {
75-
if (state.value._tag === 'repository') {
76-
if (state.value.status._tag === 'idle')
77-
void submitRepository(state.value.repository)
78-
return
79-
}
80-
void goToResults()
81-
}
82-
83-
function onEnter(event: KeyboardEvent): void {
84-
if (event.isComposing || event.keyCode === 229)
85-
return
86-
event.preventDefault()
87-
if (open.value && keyboardSelection.value && activeRow.value) {
88-
void select(activeRow.value)
89-
return
90-
}
91-
submitQuery()
92-
}
93-
94-
function onArrow(event: KeyboardEvent, delta: number): void {
95-
if (event.isComposing || event.keyCode === 229)
96-
return
97-
event.preventDefault()
98-
openPanel()
99-
const count = rows.value.length
100-
if (!count)
101-
return
102-
activeIndex.value = keyboardSelection.value
103-
? (activeIndex.value + delta + count) % count
104-
: delta > 0 ? 0 : count - 1
105-
keyboardSelection.value = true
106-
}
16+
openPanel,
17+
dismiss,
18+
select,
19+
submitQuery,
20+
onEnter,
21+
onArrow,
22+
onColumn,
23+
gridId,
24+
activeDescendant,
25+
} = useSkillSearchBox({ inputs: () => [input.value?.inputRef] })
10726
10827
function onFocusOut(event: FocusEvent): void {
10928
if (!container.value?.contains(event.relatedTarget as Node | null))
@@ -124,10 +43,6 @@ watch(() => route.fullPath, () => {
12443
close()
12544
})
12645
127-
const listboxId = computed(() => (open.value && rows.value.length ? 'skill-search-listbox' : undefined))
128-
const activeDescendant = computed(() =>
129-
open.value && keyboardSelection.value && activeRow.value ? `skill-search-row-${activeIndex.value}` : undefined,
130-
)
13146
const submitDisabled = computed(() => state.value._tag === 'repository' && state.value.status._tag !== 'idle')
13247
const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index repository' : 'Search'))
13348
</script>
@@ -150,8 +65,9 @@ const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index r
15065
autocomplete="off"
15166
role="combobox"
15267
aria-label="Search skills or index a GitHub repository"
68+
aria-haspopup="grid"
15369
:aria-expanded="open"
154-
:aria-controls="listboxId"
70+
:aria-controls="gridId"
15571
:aria-activedescendant="activeDescendant"
15672
class="w-full"
15773
:ui="{
@@ -164,6 +80,8 @@ const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index r
16480
@input="openPanel"
16581
@keydown.down="onArrow($event, 1)"
16682
@keydown.up="onArrow($event, -1)"
83+
@keydown.left="onColumn($event, 0)"
84+
@keydown.right="onColumn($event, 1)"
16785
@keydown.enter="onEnter"
16886
>
16987
<template #trailing>
@@ -185,7 +103,7 @@ const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index r
185103
<Transition name="home-search-panel">
186104
<div v-if="open" class="absolute inset-x-0 top-full z-30 mt-2">
187105
<!-- Lazy, as in SkillSearchTrigger: nothing renders it until search opens. -->
188-
<LazySkillSearchPanel :show-preview="false" @select="(row) => { void select(row) }" />
106+
<LazySkillSearchPanel @select="(row) => { void select(row) }" />
189107
</div>
190108
</Transition>
191109

0 commit comments

Comments
 (0)