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'
|
import FandomPicker from './FandomPicker.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['pick-existing', 'pick-new', 'accept-suggestion', 'cancel'])
|
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()
|
const store = useTagStore()
|
||||||
// The image's ML (Camie) suggestions, surfaced inline in this dropdown so the
|
// 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
|
// 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)}%` }
|
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
|
// 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.
|
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
||||||
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
|
// 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 suggestionHits = computed(() => {
|
||||||
const q = parsedName.value.toLowerCase()
|
const q = parsedName.value.toLowerCase()
|
||||||
if (!q) return []
|
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 = []
|
const out = []
|
||||||
for (const list of Object.values(suggestions.allByCategory)) {
|
for (const list of Object.values(suggestions.allByCategory)) {
|
||||||
for (const s of list || []) {
|
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,
|
// 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).
|
// whose job is finding a tag to ADD (un-reject lives in the panel).
|
||||||
if (s.rejected) continue
|
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()}`
|
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||||
|
if (appliedNames.value.has(key)) continue
|
||||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||||
if (seen.has(key)) continue
|
if (seen.has(key)) continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@@ -242,7 +266,7 @@ const suggestionHits = computed(() => {
|
|||||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
// 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.
|
// highlight index maps 1:1 to a row regardless of which section it's in.
|
||||||
const rows = computed(() => {
|
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) {
|
for (const s of suggestionHits.value) {
|
||||||
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
r.push({ type: 'suggestion', key: `s${s.category}:${s.display_name}`, sugg: s })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<TagAutocomplete
|
<TagAutocomplete
|
||||||
ref="tagInputRef"
|
ref="tagInputRef"
|
||||||
|
:applied-tags="host.current?.tags || []"
|
||||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||||
@accept-suggestion="onAcceptSuggestion"
|
@accept-suggestion="onAcceptSuggestion"
|
||||||
/>
|
/>
|
||||||
@@ -107,17 +108,59 @@ defineExpose({ focusTagInput })
|
|||||||
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
|
// (operator-asked 2026-06-26; the Explore workspace leans on this hard).
|
||||||
async function onRemove(tagId) {
|
async function onRemove(tagId) {
|
||||||
errorMsg.value = null
|
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 }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
async function onPickExisting(hit) {
|
async function onPickExisting(hit) {
|
||||||
errorMsg.value = null
|
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 }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
async function onPickNew(payload) {
|
async function onPickNew(payload) {
|
||||||
errorMsg.value = null
|
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 }
|
catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
// A suggestion picked from the autocomplete dropdown runs the SAME path as the
|
// 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
|
if (anchor.value?.id !== id) return // walked away mid-create
|
||||||
await addExistingTag(tag.id)
|
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
|
// 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
|
if (currentImageId.value !== imageId) return // navigated away
|
||||||
await addExistingTag(tag.id)
|
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)
|
const isOpen = computed(() => currentImageId.value !== null)
|
||||||
|
|||||||
@@ -169,6 +169,29 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
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) {
|
async function dismiss(suggestion) {
|
||||||
const imageId = currentImageId
|
const imageId = currentImageId
|
||||||
if (imageId == null) return
|
if (imageId == null) return
|
||||||
@@ -202,6 +225,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
byCategory, allByCategory, loading, error,
|
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