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
+3
View File
@@ -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
+3
View File
@@ -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)
+25 -1
View File
@@ -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
}
})
+90
View File
@@ -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)
})
})