feat(modal): applied tags drop from search instantly; manual add == accepting a suggestion
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m50s
CI / integration (push) Successful in 3m53s

Three tag-flow gaps in the view modal (and the Explore workspace, which
shares TagPanel):
- the type-to-add dropdown now filters both its sections against the
  imageledger applied tags reactively, so a just-added tag disappears
  from search the moment the chip rail updates instead of after a
  modal refresh
- manually picking or creating a tag the model also suggested routes
  through the suggestion-accept flow: the acceptance is recorded for
  head training and the row leaves the panel, instead of the add
  silently bypassing the feedback loop
- removing a tag reloads the suggestion lists, so a model-suggested tag
  returns to the suggestions area (flagged rejected, one-click
  reversible) rather than vanishing until the next modal open

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 22:00:40 -04:00
parent b54243a1ff
commit 6d314d662f
6 changed files with 193 additions and 6 deletions
@@ -100,6 +100,13 @@ import { useSuggestionsStore } from '../../stores/suggestions.js'
import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
// The host surface's applied tags (host.current?.tags). Both dropdown sections
// filter against this so a just-added tag drops out of the search the moment
// the chip rail updates, not after the next modal refresh (operator-asked
// 2026-07-03).
const props = defineProps({
appliedTags: { type: Array, default: () => [] },
})
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
@@ -208,6 +215,18 @@ const createLabel = computed(() =>
function scorePct (s) { return `${Math.round(s.score * 100)}%` }
const appliedIds = computed(() => new Set(props.appliedTags.map(t => t.id)))
const appliedNames = computed(() =>
new Set(props.appliedTags.map(t => `${t.kind}:${(t.name || '').toLowerCase()}`)),
)
// Server matches minus the image's applied tags. allowCreate/sameNameCharExists
// keep reading the RAW hits — they reason about the tag universe (does this tag
// exist?), not about this image.
const visibleHits = computed(() =>
hits.value.filter(h => !appliedIds.value.has(h.id)),
)
// 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.
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
@@ -218,7 +237,7 @@ function scorePct (s) { return `${Math.round(s.score * 100)}%` }
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 seen = new Set(visibleHits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
const out = []
for (const list of Object.values(suggestions.allByCategory)) {
for (const s of list || []) {
@@ -226,7 +245,12 @@ const suggestionHits = computed(() => {
// can show + un-reject them; keep them OUT of the type-to-add dropdown,
// whose job is finding a tag to ADD (un-reject lives in the panel).
if (s.rejected) continue
// Already on the image (matched by canonical id or name+kind): nothing
// to add. Covers the window between an add and the next suggestions
// fetch, where the stale row would otherwise still be pickable.
if (s.canonical_tag_id != null && appliedIds.value.has(s.canonical_tag_id)) continue
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (appliedNames.value.has(key)) continue
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
seen.add(key)
@@ -242,7 +266,7 @@ const suggestionHits = computed(() => {
// 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 }))
const r = visibleHits.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 })
}
+46 -3
View File
@@ -15,6 +15,7 @@
<TagAutocomplete
ref="tagInputRef"
:applied-tags="host.current?.tags || []"
@pick-existing="onPickExisting" @pick-new="onPickNew"
@accept-suggestion="onAcceptSuggestion"
/>
@@ -107,17 +108,59 @@ defineExpose({ focusTagInput })
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
async function onRemove(tagId) {
errorMsg.value = null
try { await host.removeTag(tagId); focusTagInput() }
try {
await host.removeTag(tagId)
// removeTag also records a per-image dismissal server-side, so reloading
// puts a model-suggested tag BACK in the suggestions area (flagged
// rejected — visible + one-click reversible) instead of it vanishing
// until the next modal open (operator-asked 2026-07-03).
if (host.currentImageId != null) {
suggestions.load(host.currentImageId)
suggestions.loadAll(host.currentImageId)
}
focusTagInput()
}
catch (e) { errorMsg.value = e.message }
}
async function onPickExisting(hit) {
errorMsg.value = null
try { await host.addExistingTag(hit.id); focusTagInput() }
try {
// Manually picking a tag the model also suggested = accepting the
// suggestion: the acceptance is recorded and the row drops from the
// panel, instead of silently bypassing the feedback loop
// (operator-asked 2026-07-03).
const pending = suggestions.findPending(hit.kind, hit.name, hit.id)
if (pending) {
await suggestions.accept({
...pending,
canonical_tag_id: pending.canonical_tag_id ?? hit.id,
})
await host.reloadTags()
} else {
await host.addExistingTag(hit.id)
}
focusTagInput()
}
catch (e) { errorMsg.value = e.message }
}
async function onPickNew(payload) {
errorMsg.value = null
try { await host.createAndAdd(payload); focusTagInput() }
try {
const imageId = host.currentImageId
const created = await host.createAndAdd(payload)
// The typed name can match a raw (or canonical) suggestion; creating it
// by hand still counts as accepting — record it and drop the row. Runs
// AFTER createAndAdd so a character's fandom pick is preserved (accept's
// own create path knows no fandom); the accept apply is idempotent.
// Guard on the image not having changed mid-create (fast prev/next).
if (created && host.currentImageId === imageId) {
const pending = suggestions.findPending(payload.kind, payload.name, created.id)
if (pending) {
await suggestions.accept({ ...pending, canonical_tag_id: created.id })
}
}
focusTagInput()
}
catch (e) { errorMsg.value = e.message }
}
// A suggestion picked from the autocomplete dropdown runs the SAME path as the