feat(suggestions): visible, reversible rejection in the modal rail
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Failing after 3m19s

A red-✗ dismissal no longer makes the suggestion vanish. The rejected tag
stays in the rail — dimmed, struck-through, with a "rejected" pill and a
one-click undo (↶) in place of the ✗ — so a misclick is recoverable and the
operator can see what they've said no to (operator-asked 2026-06-27).

Backend: SuggestionService.for_image now KEEPS rejected tags, flagged
rejected=True, sorted to the bottom of their category, instead of dropping
them. New AllowlistService.undismiss + POST /suggestions/undismiss clears the
TagSuggestionRejection. Rejected items are still excluded from bulk consensus
(for_selection) and the type-to-add dropdown, whose jobs are unchanged.

Frontend: store.dismiss flags in place (canonical tags) rather than dropping;
new store.undismiss reverts. SuggestionItem renders the rejected state and
swaps ✗→↶; ✓ still accepts (which clears the rejection server-side).

Tests: rejected-surfaced-flagged-then-reversible (service) + undismiss
endpoint idempotency (API).

Completes #1134's reversible-rejection half. Heads-as-suggestion-source is
the remaining piece.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-28 09:49:05 -04:00
parent 1d39afa3b6
commit 179c1a9dcc
10 changed files with 188 additions and 23 deletions
@@ -3,10 +3,12 @@
name, score, and action buttons as one "object" (operator-asked
2026-06-01). The row itself is informational; the green / red
verdict pair + 3-dot alias menu are the action affordances. -->
<div class="fc-suggestion">
<div class="fc-suggestion" :class="{ 'fc-suggestion--rejected': suggestion.rejected }">
<span class="fc-suggestion__name">
{{ suggestion.display_name }}
<span v-if="suggestion.creates_new_tag" class="fc-suggestion__new"
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
title="You rejected this for this image — un-reject to recover">rejected</span>
<span v-else-if="suggestion.creates_new_tag" class="fc-suggestion__new"
title="No matching tag yet — accepting creates it">+ new</span>
<span v-else-if="suggestion.via_alias" class="fc-suggestion__alias"
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
@@ -17,7 +19,8 @@
for this image (records a TagSuggestionRejection — a hard negative the
heads train on). Together they occupy ~the footprint of the old single
Accept pill, so rejecting is now a one-click peer of accepting rather
than buried in the kebab. -->
than buried in the kebab. When the row is already rejected the ✗ swaps
to an undo (↶) so the rejection is reversible in place. -->
<div class="fc-suggestion__acts">
<button
class="fc-act fc-act--yes" type="button"
@@ -26,6 +29,14 @@
@click="$emit('accept', suggestion)"
><v-icon size="16">mdi-check</v-icon></button>
<button
v-if="suggestion.rejected"
class="fc-act fc-act--undo" type="button"
:aria-label="`Un-reject ${suggestion.display_name}`"
:title="`Undo — restore ${suggestion.display_name} as a suggestion`"
@click="$emit('undismiss', suggestion)"
><v-icon size="16">mdi-undo-variant</v-icon></button>
<button
v-else
class="fc-act fc-act--no" type="button"
:aria-label="`Reject ${suggestion.display_name}`"
:title="`No — not ${suggestion.display_name}`"
@@ -65,7 +76,7 @@ import { computed } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss'])
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
// Kebab now only carries alias actions: show it when this suggestion can be
@@ -137,7 +148,35 @@ const hasMenu = computed(() =>
}
.fc-act--yes { background: rgb(var(--v-theme-success)); }
.fc-act--no { background: rgb(var(--v-theme-error)); }
/* Undo reads as neutral-secondary, not a verdict: outlined, not filled. */
.fc-act--undo {
background: transparent; color: rgb(var(--v-theme-on-surface-variant));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.5);
}
.fc-suggestion__menu {
flex: 0 0 auto;
}
/* Rejected state: the row stays put (recovery), dimmed + red-edged so it
reads as "handled, negative" without shouting over live suggestions. */
.fc-suggestion--rejected {
border-color: rgb(var(--v-theme-error), 0.4);
background: rgb(var(--v-theme-error), 0.06);
}
.fc-suggestion--rejected .fc-suggestion__name {
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: line-through;
text-decoration-color: rgb(var(--v-theme-error), 0.6);
}
.fc-suggestion__rejected-tag {
display: inline-block;
font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-error));
background: rgb(var(--v-theme-error), 0.12);
border: 1px solid rgb(var(--v-theme-error), 0.4);
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
text-decoration: none;
}
</style>
@@ -18,6 +18,7 @@
@alias="$emit('alias', $event)"
@remove-alias="$emit('remove-alias', $event)"
@dismiss="$emit('dismiss', $event)"
@undismiss="$emit('undismiss', $event)"
/>
</div>
</div>
@@ -33,7 +34,7 @@ const props = defineProps({
collapsible: { type: Boolean, default: false },
defaultOpen: { type: Boolean, default: true }
})
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss'])
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
const open = ref(props.collapsible ? props.defaultOpen : true)
</script>
@@ -21,14 +21,14 @@
v-show="store.byCategory[cat] && store.byCategory[cat].length"
:label="labelFor(cat)" :items="store.byCategory[cat] || []"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="store.dismiss"
@dismiss="store.dismiss" @undismiss="store.undismiss"
/>
<SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length"
label="General" :items="store.byCategory.general"
collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @remove-alias="onRemoveAlias"
@dismiss="store.dismiss"
@dismiss="store.dismiss" @undismiss="store.undismiss"
/>
</div>
@@ -202,6 +202,10 @@ const suggestionHits = computed(() => {
const out = []
for (const list of Object.values(suggestions.allByCategory)) {
for (const s of list || []) {
// Rejected suggestions now stay in allByCategory (flagged) so the panel
// 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
const key = `${s.category}:${s.display_name.toLowerCase()}`
if (!s.display_name.toLowerCase().includes(q)) continue
if (seen.has(key)) continue
+37 -9
View File
@@ -84,6 +84,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}
}
// Flip the `rejected` flag on the matching suggestion in BOTH lists in place,
// so a reject/un-reject shows immediately without dropping the row (visible,
// reversible rejection — misclick recovery, operator-asked 2026-06-27).
function _setRejectedEverywhere(suggestion, value) {
const key = _keyOf(suggestion)
const cat = suggestion.category
for (const map of [byCategory.value, allByCategory.value]) {
for (const s of map[cat] || []) {
if (_keyOf(s) === key) s.rejected = value
}
}
}
async function accept(suggestion) {
// Capture imageId so a mid-flight prev/next can't reroute the
// accept POST to a different image AND push the tag to that
@@ -168,21 +181,36 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
async function dismiss(suggestion) {
const imageId = currentImageId
if (imageId == null) return
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
// suggestion just hides it client-side (nothing to persist a rejection
// against until the tag exists).
if (suggestion.canonical_tag_id != null) {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
// Dismiss needs a tag_id. Raw tags (creates_new_tag) have none, so there's
// nothing to persist a rejection against — drop them client-side as before.
// Canonical tags persist a rejection and STAY in the list flagged rejected,
// so the operator can see it and one-click un-reject (misclick recovery).
if (suggestion.canonical_tag_id == null) {
if (currentImageId === imageId) _dropEverywhere(suggestion)
return
}
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
if (currentImageId === imageId) {
_dropEverywhere(suggestion)
_setRejectedEverywhere(suggestion, true)
}
}
// Undo a per-image dismissal — the suggestion reverts to a live row.
async function undismiss(suggestion) {
const imageId = currentImageId
if (imageId == null || suggestion.canonical_tag_id == null) return
await api.post(`/api/images/${imageId}/suggestions/undismiss`, {
body: { tag_id: suggestion.canonical_tag_id }
})
if (currentImageId === imageId) {
_setRejectedEverywhere(suggestion, false)
}
}
return {
byCategory, allByCategory, loading, error,
load, loadAll, accept, aliasAccept, removeAlias, dismiss
load, loadAll, accept, aliasAccept, removeAlias, dismiss, undismiss
}
})