feat(modal): surface ML suggestions inline in the tag autocomplete
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m2s

The image's Camie suggestions now appear in the tag input's dropdown as you
type, filtered to the query and de-duped against the server autocomplete hits,
so the operator can pick a suggestion without hunting for it in the Suggestions
panel below (operator-asked 2026-06-07).

- Unified `rows` model (hits → matching suggestions → create row) so the
  highlight index maps 1:1 to a row across all three sections; arrow/Enter/Tab
  drive the whole list.
- Suggestion rows are marked (accent left-border + mdi-auto-fix score chip) and
  show a "new" hint when the suggestion would create a tag.
- Picking a suggestion emits accept-suggestion → TagPanel runs the SAME accept
  path as the Suggestions panel (creates raw tags, records acceptance, drops it
  from the panel), then refreshes the chip rail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:56:50 -04:00
parent b79708524e
commit 5201fab088
2 changed files with 143 additions and 43 deletions
+130 -43
View File
@@ -12,40 +12,70 @@
@keydown.esc="$emit('cancel')"
/>
<v-list
v-if="hits.length || allowCreate"
v-if="rows.length"
ref="listRef"
density="compact" class="fc-tag-autocomplete__list"
>
<v-list-item
v-for="(h, idx) in hits" :key="h.id"
:active="idx === highlight" @click="onPick(h)"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(h.kind)">
{{ iconFor(h.kind) }}
</v-icon>
</template>
<v-list-item-title>
{{ h.name }}
<span v-if="h.fandom_name" class="text-caption"> {{ h.fandom_name }}</span>
</v-list-item-title>
<template #append>
<span class="text-caption">{{ h.kind }}</span>
</template>
</v-list-item>
<v-list-item
v-if="allowCreate" :active="highlight === hits.length"
@click="onCreate"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(parsedKind)">
{{ iconFor(parsedKind) }}
</v-icon>
</template>
<v-list-item-title>
Create "{{ parsedName }}" as {{ parsedKind }}
</v-list-item-title>
</v-list-item>
<template v-for="(row, idx) in rows" :key="row.key">
<!-- Existing tag match (server autocomplete). -->
<v-list-item
v-if="row.type === 'hit'"
:active="idx === highlight" @click="onPickRow(row)"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(row.hit.kind)">
{{ iconFor(row.hit.kind) }}
</v-icon>
</template>
<v-list-item-title>
{{ row.hit.name }}
<span v-if="row.hit.fandom_name" class="text-caption"> {{ row.hit.fandom_name }}</span>
</v-list-item-title>
<template #append>
<span class="text-caption">{{ row.hit.kind }}</span>
</template>
</v-list-item>
<!-- ML suggestion for THIS image that matches the typed query. Picking
it routes through the same accept path as the Suggestions panel, so
it's recorded + drops out of the panel (operator-asked 2026-06-07). -->
<v-list-item
v-else-if="row.type === 'suggestion'"
:active="idx === highlight" @click="onPickRow(row)"
class="fc-tag-autocomplete__sugg"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(row.sugg.category)">
{{ iconFor(row.sugg.category) }}
</v-icon>
</template>
<v-list-item-title>
{{ row.sugg.display_name }}
<span v-if="row.sugg.creates_new_tag" class="text-caption">— new</span>
</v-list-item-title>
<template #append>
<span class="fc-tag-autocomplete__sugg-tag">
<v-icon size="x-small">mdi-auto-fix</v-icon>
{{ scorePct(row.sugg) }}
</span>
</template>
</v-list-item>
<!-- Create-new row. -->
<v-list-item
v-else
:active="idx === highlight" @click="onPickRow(row)"
>
<template #prepend>
<v-icon size="small" :color="store.colorFor(parsedKind)">
{{ iconFor(parsedKind) }}
</v-icon>
</template>
<v-list-item-title>
Create "{{ parsedName }}" as {{ parsedKind }}
</v-list-item-title>
</v-list-item>
</template>
</v-list>
<!-- @after-enter is the reliable moment to focus the picker: the dialog's
@@ -66,10 +96,15 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
const store = useTagStore()
// The image's ML (Camie) suggestions, surfaced inline in this dropdown so the
// operator can pick a suggestion while typing instead of hunting for it in the
// Suggestions panel below (operator-asked 2026-06-07).
const suggestions = useSuggestionsStore()
// Autofocus on modal open so the operator can type the moment the view
// modal renders, no extra click required (operator-asked 2026-06-01).
@@ -147,8 +182,42 @@ const allowCreate = computed(() => {
)
})
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
// This image's suggestions that match the typed query, minus any the server
// autocomplete already returned (same name+kind) so a tag never shows twice.
const suggestionHits = computed(() => {
const q = parsedName.value.toLowerCase()
if (!q) return []
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
const out = []
for (const list of Object.values(suggestions.byCategory)) {
for (const s of list || []) {
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
seen.add(key)
out.push(s)
}
}
// Best matches first; cap so the dropdown stays scannable.
out.sort((a, b) => b.score - a.score)
return out.slice(0, 6)
})
// One ordered list backing both the rendered dropdown and keyboard nav, so the
// highlight index maps 1:1 to a row regardless of which section it's in.
const rows = computed(() => {
const r = hits.value.map(h => ({ type: 'hit', key: `h${h.id}`, hit: h }))
for (const s of suggestionHits.value) {
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
}
if (allowCreate.value) r.push({ type: 'create', key: 'create' })
return r
})
function moveHighlight (delta) {
const total = hits.value.length + (allowCreate.value ? 1 : 0)
const total = rows.value.length
if (total === 0) return
highlight.value = (highlight.value + delta + total) % total
// Keep the highlighted row visible — the list is capped at 240px and arrowing
@@ -161,7 +230,18 @@ function moveHighlight (delta) {
})
}
function onPick (hit) { emit('pick-existing', hit); reset() }
// Dispatch a chosen dropdown row by its type.
function onPickRow (row) {
if (row.type === 'hit') { emit('pick-existing', row.hit); reset() }
else if (row.type === 'suggestion') { onPickSuggestion(row.sugg) }
else { onCreate() }
}
// Picking an ML suggestion hands it to the parent, which runs the same accept
// flow as the Suggestions panel (creates the tag if raw, records acceptance,
// drops it from the panel). reset() clears the query; the panel-drop makes it
// fall out of suggestionHits reactively.
function onPickSuggestion (s) { emit('accept-suggestion', s); reset() }
function onCreate () {
const name = parsedName.value
@@ -203,18 +283,15 @@ function onFandomCancel () {
}
function onEnter () {
if (highlight.value < hits.value.length) {
onPick(hits.value[highlight.value])
} else if (allowCreate.value) {
onCreate()
}
const row = rows.value[highlight.value]
if (row) onPickRow(row)
}
// B5 (2026-06-07): when the suggestion list is open, Tab accepts the
// highlighted row (standard autocomplete convention) instead of leaving the
// field. With the list closed it falls through to normal focus traversal.
// B5 (2026-06-07): when the dropdown is open, Tab accepts the highlighted row
// (standard autocomplete convention) instead of leaving the field. With the
// list closed it falls through to normal focus traversal.
function onTab (e) {
if (hits.value.length || allowCreate.value) {
if (rows.value.length) {
e.preventDefault()
onEnter()
}
@@ -232,4 +309,14 @@ function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
}
/* Mark ML-suggestion rows so they read as distinct from typed/known matches. */
.fc-tag-autocomplete__sugg {
border-left: 2px solid rgb(var(--v-theme-accent), 0.5);
}
.fc-tag-autocomplete__sugg-tag {
display: inline-flex; align-items: center; gap: 2px;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-accent));
}
</style>
@@ -14,6 +14,7 @@
<TagAutocomplete
@pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/>
<v-alert v-if="errorMsg" type="error" variant="tonal" class="mt-2" closable>
@@ -45,6 +46,7 @@
<script setup>
import { ref } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { useSuggestionsStore } from '../../stores/suggestions.js'
import TagChip from './TagChip.vue'
import TagAutocomplete from './TagAutocomplete.vue'
import SuggestionsPanel from './SuggestionsPanel.vue'
@@ -52,6 +54,7 @@ import TagRenameDialog from './TagRenameDialog.vue'
import FandomSetDialog from './FandomSetDialog.vue'
const modal = useModalStore()
const suggestions = useSuggestionsStore()
const errorMsg = ref(null)
async function onRemove(tagId) {
@@ -69,6 +72,16 @@ async function onPickNew(payload) {
try { await modal.createAndAdd(payload) }
catch (e) { errorMsg.value = e.message }
}
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
// Suggestions panel's Accept: the store creates the tag if it's raw, records the
// acceptance, and drops it from the panel; then we refresh the chip rail.
async function onAcceptSuggestion(s) {
errorMsg.value = null
try {
await suggestions.accept(s)
await modal.reloadTags()
} catch (e) { errorMsg.value = e.message }
}
const renameDialog = ref(false)
const renameTarget = ref(null)