feat(modal): applied tags drop from search instantly; manual add == accepting a suggestion
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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -176,6 +176,9 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
}
|
||||
if (anchor.value?.id !== id) return // walked away mid-create
|
||||
await addExistingTag(tag.id)
|
||||
// Returned so TagPanel can match the created tag against a pending
|
||||
// suggestion and record the acceptance (manual add == accept, 2026-07-03).
|
||||
return tag
|
||||
}
|
||||
|
||||
// No overlay to dismiss in the Explore workspace — the chip-body "show me
|
||||
|
||||
@@ -170,6 +170,9 @@ export const useModalStore = defineStore('modal', () => {
|
||||
}
|
||||
if (currentImageId.value !== imageId) return // navigated away
|
||||
await addExistingTag(tag.id)
|
||||
// Returned so TagPanel can match the created tag against a pending
|
||||
// suggestion and record the acceptance (manual add == accept, 2026-07-03).
|
||||
return tag
|
||||
}
|
||||
|
||||
const isOpen = computed(() => currentImageId.value !== null)
|
||||
|
||||
@@ -169,6 +169,29 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
||||
}
|
||||
|
||||
// Find a live (non-rejected) pending suggestion matching a manually-picked
|
||||
// tag — by canonical id when known, else by (kind, name). Manually adding a
|
||||
// tag the model also suggested must be indistinguishable from accepting the
|
||||
// suggestion (recorded + dropped from the panel), so TagPanel routes matches
|
||||
// through accept() (operator-asked 2026-07-03). Searches the full dropdown
|
||||
// set first, then the panel list (loadAll is best-effort and can be empty).
|
||||
function findPending(kind, name, tagId = null) {
|
||||
const lname = (name || '').toLowerCase()
|
||||
for (const map of [allByCategory.value, byCategory.value]) {
|
||||
for (const list of Object.values(map)) {
|
||||
for (const s of list || []) {
|
||||
if (s.rejected) continue
|
||||
if (tagId != null && s.canonical_tag_id === tagId) return s
|
||||
if (
|
||||
s.category === kind &&
|
||||
(s.display_name || '').toLowerCase() === lname
|
||||
) return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function dismiss(suggestion) {
|
||||
const imageId = currentImageId
|
||||
if (imageId == null) return
|
||||
@@ -202,6 +225,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||
|
||||
return {
|
||||
byCategory, allByCategory, loading, error,
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss
|
||||
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss,
|
||||
findPending
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useSuggestionsStore } from '../src/stores/suggestions.js'
|
||||
|
||||
vi.mock('../src/utils/toast.js', () => ({ toast: vi.fn() }))
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
const { status, body } = handler(url, init)
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
text: async () => (body == null ? '' : JSON.stringify(body)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sugg = (over = {}) => ({
|
||||
canonical_tag_id: 7,
|
||||
display_name: 'Ichigo',
|
||||
category: 'character',
|
||||
score: 0.9,
|
||||
source: 'head',
|
||||
creates_new_tag: false,
|
||||
rejected: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
// Seed the store through its own load/loadAll so currentImageId is set the
|
||||
// way the panel sets it (accept() derives the image from it).
|
||||
async function seed(store, byCategory) {
|
||||
stubFetch(() => ({ status: 200, body: { by_category: byCategory } }))
|
||||
await store.load(1)
|
||||
await store.loadAll(1)
|
||||
}
|
||||
|
||||
describe('suggestions store: findPending (manual add == accept, 2026-07-03)', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('matches by canonical tag id', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, { character: [sugg()] })
|
||||
expect(s.findPending('general', 'unrelated', 7)?.display_name).toBe('Ichigo')
|
||||
})
|
||||
|
||||
it('matches raw suggestions by kind + name, case-insensitively', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, {
|
||||
general: [sugg({
|
||||
canonical_tag_id: null, display_name: 'Holding Sword',
|
||||
category: 'general', creates_new_tag: true,
|
||||
})],
|
||||
})
|
||||
expect(s.findPending('general', 'holding sword')).toBeTruthy()
|
||||
// Kind must match: same name under another kind is a different tag.
|
||||
expect(s.findPending('character', 'holding sword')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips rejected rows and returns null when nothing matches', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, { character: [sugg({ rejected: true })] })
|
||||
expect(s.findPending('character', 'Ichigo', 7)).toBeNull()
|
||||
expect(s.findPending('character', 'Rukia')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions store: accept with a known tag id', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('POSTs only the accept endpoint (no tag create) and drops the row', async () => {
|
||||
const s = useSuggestionsStore()
|
||||
await seed(s, { character: [sugg()] })
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, method: init?.method })
|
||||
return { status: 200, body: { accepted: true, tag_id: 7 } }
|
||||
})
|
||||
// TagPanel routes a manual pick that matches a pending suggestion through
|
||||
// accept, filling canonical_tag_id from the picked hit when the
|
||||
// suggestion is raw — no /api/tags create must fire in that case.
|
||||
await s.accept({ ...s.byCategory.character[0], canonical_tag_id: 7 })
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toContain('/api/images/1/suggestions/accept')
|
||||
expect(s.byCategory.character).toHaveLength(0)
|
||||
expect(s.allByCategory.character).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user