From e89c4d40ea0641617f5895cc59d7374d3091a0f7 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 7 Oct 2026 01:08:25 -0700 Subject: [PATCH 1/2] Online tagging: toolbar Review button instead of an auto-opening dialog Every tag-prompt socket message ran loadPrompts() with auto-open on, so a large tagging run reopened the Match Review dialog in every admin tab each time the daemon deferred a match. The passive cues were weak: an uncounted amber dot on the hamburger and a drawer item. - loadPrompts() only ever closes the dialog now; it opens from a click. - New OnlineTagReviewButton (tonal warning, "Review N", icon + count on phones) in the browser top toolbar and the admin title bar. - app.vue head() prefixes the document title with "(N) " for admins, so a background tab shows the count. - pendingComicCount moves into the store so the drawer label, the button and the title agree; the hamburger prompts dot is removed. The button is a plain v-btn, not ScaleButton: compact density on xs stacks with size="small" and measured 16px tall in the browser. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 6 ++ frontend/src/app.vue | 14 ++++ .../src/components/admin/admin-header.vue | 3 + .../components/admin/drawer/admin-menu.vue | 19 ++--- .../drawer/admin-settings-button-prompts.vue | 45 ----------- .../toolbars/top/browser-toolbar-top.vue | 3 + .../components/online-tag/review-button.vue | 74 ++++++++++++++++++ frontend/src/components/settings/button.vue | 9 --- frontend/src/stores/online-tag.js | 44 ++++++++--- frontend/tests/unit/online-tag-store.test.js | 61 +++++++++++++-- frontend/tests/unit/review-button.test.js | 78 +++++++++++++++++++ 11 files changed, 270 insertions(+), 86 deletions(-) delete mode 100644 frontend/src/components/admin/drawer/admin-settings-button-prompts.vue create mode 100644 frontend/src/components/online-tag/review-button.vue create mode 100644 frontend/tests/unit/review-button.test.js diff --git a/NEWS.md b/NEWS.md index 159343399..77f102bb5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,12 @@ width: 128px; border-radius: 128px; " /> +## v2.5.4 + +- Features + - Online tagging matches to review show a toolbar Review button and a + tab-title count instead of popping up a dialog. + ## v2.5.3 - Features diff --git a/frontend/src/app.vue b/frontend/src/app.vue index 6b634372e..e98167ef5 100644 --- a/frontend/src/app.vue +++ b/frontend/src/app.vue @@ -13,6 +13,7 @@ import DockerHubDeprecatedSnackbar from "@/components/docker-hub-deprecated-snac import SessionErrorSnackbar from "@/components/session-error-snackbar.vue"; import { useAuthStore } from "@/stores/auth"; import { useFavoritesStore } from "@/stores/favorites"; +import { pendingTitle, useOnlineTagStore } from "@/stores/online-tag"; import { useSocketStore } from "@/stores/socket"; export default { @@ -21,7 +22,20 @@ export default { DockerHubDeprecatedSnackbar, SessionErrorSnackbar, }, + head() { + /* + * "(N) " on whatever title the page sets, so a background tab shows + * matches waiting for review. Read the count here, not inside the + * template function: unhead calls that at render time, outside this + * effect, so only what head() itself reads re-runs it. + */ + const count = this.isUserAdmin ? this.pendingComicCount : 0; + if (!count) return {}; + return { titleTemplate: (title) => pendingTitle(count, title) }; + }, computed: { + ...mapState(useOnlineTagStore, ["pendingComicCount"]), + ...mapState(useAuthStore, ["isUserAdmin"]), ...mapState(useAuthStore, { user: (state) => state.user, /* diff --git a/frontend/src/components/admin/admin-header.vue b/frontend/src/components/admin/admin-header.vue index e382fbf72..b177a68a4 100644 --- a/frontend/src/components/admin/admin-header.vue +++ b/frontend/src/components/admin/admin-header.vue @@ -4,6 +4,7 @@ Codex Administration + 0; @@ -97,14 +97,7 @@ export default { return this.pendingPrompts.length > 0; }, promptsLabel() { - // Comics, not questions: one question can hold a whole series, and the - // Tagging tab counts the rows it will mark for review. A prompt that - // names no comic at all still counts as one thing to look at, so a - // malformed cache entry can't make a visible queue read as empty. - const count = this.pendingPrompts.reduce( - (total, prompt) => total + Math.max(promptComics(prompt).length, 1), - 0, - ); + const count = this.pendingComicCount; return `${count} Match${count === 1 ? "" : "es"} to Review`; }, showAdminPanelLink() { @@ -127,9 +120,9 @@ export default { this.librarianTask("poll"); }, openPrompts() { - // The OnlineTagPromptPopup (mounted in browser.vue + admin.vue) watches - // this flag; flipping it opens the same Match Review dialog the browser - // toolbar button does. + // The OnlineTagPromptPopup (mounted in browser.vue, admin.vue and + // reader.vue) watches this flag; flipping it opens the same Match + // Review dialog the toolbar Review button does. this.promptDialogOpen = true; }, }, diff --git a/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue b/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue deleted file mode 100644 index 69e81096d..000000000 --- a/frontend/src/components/admin/drawer/admin-settings-button-prompts.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - diff --git a/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue b/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue index bffa71f1a..2ecfecfac 100644 --- a/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue +++ b/frontend/src/components/browser/toolbars/top/browser-toolbar-top.vue @@ -8,6 +8,7 @@ + @@ -27,6 +28,7 @@ import BrowserOrderReverseButton from "@/components/browser/toolbars/top/order-r import BrowserSearchButton from "@/components/browser/toolbars/top/search-button.vue"; import BrowserTopCollectionSelect from "@/components/browser/toolbars/top/top-collection-select.vue"; import BrowserViewModeToggle from "@/components/browser/toolbars/top/view-mode-toggle.vue"; +import OnlineTagReviewButton from "@/components/online-tag/review-button.vue"; import SettingsDrawerButton from "@/components/settings/button.vue"; import { useBrowserStore } from "@/stores/browser"; @@ -40,6 +42,7 @@ export default { BrowserOrderBySelect, BrowserOrderReverseButton, BrowserViewModeToggle, + OnlineTagReviewButton, SettingsDrawerButton, }, data() { diff --git a/frontend/src/components/online-tag/review-button.vue b/frontend/src/components/online-tag/review-button.vue new file mode 100644 index 000000000..2176836a3 --- /dev/null +++ b/frontend/src/components/online-tag/review-button.vue @@ -0,0 +1,74 @@ + + + + + + diff --git a/frontend/src/components/settings/button.vue b/frontend/src/components/settings/button.vue index 0047e6173..d1a98d0f7 100644 --- a/frontend/src/components/settings/button.vue +++ b/frontend/src/components/settings/button.vue @@ -11,7 +11,6 @@ - @@ -37,18 +36,11 @@ const AdminSettingsButtonErrors = markRaw( ), ); -const AdminSettingsButtonPrompts = markRaw( - defineAsyncComponent( - () => import("@/components/admin/drawer/admin-settings-button-prompts.vue"), - ), -); - export default { name: "SettingsDrawerButton", components: { AdminSettingsButtonErrors, AdminSettingsButtonProgress, - AdminSettingsButtonPrompts, ScaleButton, }, data() { @@ -56,7 +48,6 @@ export default { mdiMenu, AdminSettingsButtonProgress, AdminSettingsButtonErrors, - AdminSettingsButtonPrompts, }; }, computed: { diff --git a/frontend/src/stores/online-tag.js b/frontend/src/stores/online-tag.js index f75e9e3fd..f8a6a8307 100644 --- a/frontend/src/stores/online-tag.js +++ b/frontend/src/stores/online-tag.js @@ -2,6 +2,14 @@ import { defineStore } from "pinia"; import { HTTP } from "@/api/v4/base"; +/* + * The document title with a "(N) " prefix while N matches wait for review, so + * a background tab announces them. No title, nothing to prefix. + */ +export function pendingTitle(count, title) { + return title && count > 0 ? `(${count}) ${title}` : title; +} + /* * Every comic one match-review prompt answers for, representative first. * @@ -46,6 +54,18 @@ export const useOnlineTagStore = defineStore("onlineTag", { // rendered. _promptsRequestId: 0, }), + getters: { + pendingComicCount(state) { + // Comics, not questions: one question can hold a whole series, and the + // Tagging tab counts the rows it will mark for review. A prompt that + // names no comic at all still counts as one thing to look at, so a + // malformed cache entry can't make a visible queue read as empty. + return state.pendingPrompts.reduce( + (total, prompt) => total + Math.max(promptComics(prompt).length, 1), + 0, + ); + }, + }, actions: { async startSession({ collection, @@ -81,7 +101,7 @@ export const useOnlineTagStore = defineStore("onlineTag", { this.activeSessionId = sid || null; return sid; }, - async loadPrompts({ autoOpen = true } = {}) { + async loadPrompts() { /* * Pending prompts persist in the cache independently of any running * scan, so they're fetched globally — no active session required. They @@ -105,17 +125,19 @@ export const useOnlineTagStore = defineStore("onlineTag", { this.pendingPrompts = raw.filter( (p) => !this.recentlyResolved.includes(p.fingerprint), ); + /* + * Only ever closes the dialog; it opens from a click on a Review + * button. Opening it on every deferral interrupted browsing in every + * admin tab for the length of a large run. + * + * Nothing left to review: close rather than leave the dialog sitting + * on an empty list. The queue empties behind this tab's back all the + * time — another tab answered, or the daemon resolved the last one — + * and the dialog used to stay open showing a spinner that no + * notification would ever end. + */ if (this.pendingPrompts.length === 0) { - /* - * Nothing left to review: close rather than leave the dialog - * sitting on an empty list. The queue empties behind this tab's - * back all the time — another tab answered, or the daemon resolved - * the last one — and the dialog used to stay open showing a - * spinner that no notification would ever end. - */ this.promptDialogOpen = false; - } else if (autoOpen && !this.promptDialogOpen) { - this.promptDialogOpen = true; } }, rememberResolved(fingerprint) { @@ -253,7 +275,7 @@ export const useOnlineTagStore = defineStore("onlineTag", { // is live, plus any prompts still awaiting an answer and the status // snapshot for the Tagging-tab table. await this.discoverSession(); - await this.loadPrompts({ autoOpen: false }); + await this.loadPrompts(); await this.loadSnapshot(); }, onPromptNotification() { diff --git a/frontend/tests/unit/online-tag-store.test.js b/frontend/tests/unit/online-tag-store.test.js index 415c4b33b..fb08c23d5 100644 --- a/frontend/tests/unit/online-tag-store.test.js +++ b/frontend/tests/unit/online-tag-store.test.js @@ -16,7 +16,7 @@ vi.mock("@/api/v4/base", () => ({ })); import { HTTP } from "@/api/v4/base"; -import { useOnlineTagStore } from "@/stores/online-tag"; +import { pendingTitle, useOnlineTagStore } from "@/stores/online-tag"; const fps = (store) => store.pendingPrompts.map((p) => p.fingerprint); const promptsResponse = (...fingerprints) => ({ @@ -40,13 +40,13 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // Backend lagging: still returns the resolved prompt. HTTP.get.mockResolvedValue(promptsResponse("a", "b")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["b"]); // "a" stays suppressed expect(store.recentlyResolved).toEqual(["a"]); // still echoed → kept // Backend catches up and drops it. HTTP.get.mockResolvedValue(promptsResponse("b")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["b"]); expect(store.recentlyResolved).toEqual([]); // gone → forgotten }); @@ -59,7 +59,7 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // The match drifted; the daemon re-queued a fresh prompt as "a2". HTTP.get.mockResolvedValue(promptsResponse("a2")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual(["a2"]); }); @@ -76,7 +76,7 @@ describe("useOnlineTagStore — resolution reconciliation", () => { // A lagging echo of both is fully suppressed. HTTP.get.mockResolvedValue(promptsResponse("x", "y")); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual([]); }); @@ -189,6 +189,19 @@ describe("useOnlineTagStore — resolution reconciliation", () => { }); describe("useOnlineTagStore — the review dialog's lifecycle", () => { + // Opening it on every deferral interrupted browsing in every admin tab; a + // Review button opens it instead. + it("never opens the dialog on its own when prompts arrive", async () => { + const store = useOnlineTagStore(); + HTTP.get.mockResolvedValue(promptsResponse("a", "b")); + + // A deferral's socket notification: the path that used to open it. + store.onPromptNotification(); + await vi.waitFor(() => expect(fps(store)).toEqual(["a", "b"])); + + expect(store.promptDialogOpen).toBe(false); + }); + it("closes the dialog when the queue empties behind its back", async () => { const store = useOnlineTagStore(); store.pendingPrompts = [{ fingerprint: "a" }]; @@ -196,7 +209,7 @@ describe("useOnlineTagStore — the review dialog's lifecycle", () => { // Another tab answered the last one, or the daemon resolved it. HTTP.get.mockResolvedValue(promptsResponse()); - await store.loadPrompts({ autoOpen: false }); + await store.loadPrompts(); expect(fps(store)).toEqual([]); expect(store.promptDialogOpen).toBe(false); @@ -212,8 +225,8 @@ describe("useOnlineTagStore — the review dialog's lifecycle", () => { ); HTTP.get.mockResolvedValueOnce(promptsResponse("fresh")); - const first = store.loadPrompts({ autoOpen: false }); - await store.loadPrompts({ autoOpen: false }); + const first = store.loadPrompts(); + await store.loadPrompts(); releaseFirst(); await first; @@ -330,3 +343,35 @@ describe("useOnlineTagStore — loadSnapshot", () => { expect(HTTP.get).toHaveBeenCalledTimes(2); }); }); + +describe("useOnlineTagStore — pending comic count", () => { + it("counts comics, not questions", () => { + const store = useOnlineTagStore(); + store.pendingPrompts = [ + { fingerprint: "a", pk: 1, comics: [{ pk: 1 }, { pk: 2 }, { pk: 3 }] }, + // Names no comic at all; still one thing to look at. + { fingerprint: "b" }, + ]; + + expect(store.pendingComicCount).toBe(4); + }); + + it("is zero for an empty queue", () => { + expect(useOnlineTagStore().pendingComicCount).toBe(0); + }); +}); + +describe("pendingTitle", () => { + it("leaves the title alone with nothing to review", () => { + expect(pendingTitle(0, "Browse / Series")).toBe("Browse / Series"); + }); + + it("prefixes the count", () => { + expect(pendingTitle(3, "Admin / Tagging")).toBe("(3) Admin / Tagging"); + }); + + it("has nothing to prefix without a title", () => { + expect(pendingTitle(3, "")).toBe(""); + expect(pendingTitle(3)).toBeUndefined(); + }); +}); diff --git a/frontend/tests/unit/review-button.test.js b/frontend/tests/unit/review-button.test.js new file mode 100644 index 000000000..a8706153d --- /dev/null +++ b/frontend/tests/unit/review-button.test.js @@ -0,0 +1,78 @@ +/* + * Tests for the toolbar Review button that announces online-tagging matches. + * + * The Match Review dialog no longer opens on its own, so this button is the + * cue: admins only, only while matches wait, counting comics rather than + * questions, and a click opens the dialog. + */ +import { createTestingPinia } from "@pinia/testing"; +import { mount } from "@vue/test-utils"; +import { describe, expect, test } from "vitest"; + +import OnlineTagReviewButton from "@/components/online-tag/review-button.vue"; +import vuetify from "@/plugins/vuetify"; +import { useOnlineTagStore } from "@/stores/online-tag"; + +const SERIES_PROMPT = { + fingerprint: "a", + pk: 1, + comics: [{ pk: 1 }, { pk: 2 }, { pk: 3 }], +}; + +function mountButton({ + isStaff = true, + pendingPrompts = [SERIES_PROMPT], +} = {}) { + const pinia = createTestingPinia({ + stubActions: false, + initialState: { + auth: { user: { isStaff } }, + onlineTag: { pendingPrompts }, + }, + }); + const wrapper = mount(OnlineTagReviewButton, { + global: { plugins: [vuetify, pinia] }, + }); + return { wrapper, store: useOnlineTagStore(pinia) }; +} + +describe("OnlineTagReviewButton", () => { + test("hidden for non-admins", () => { + const { wrapper } = mountButton({ isStaff: false }); + + expect(wrapper.find(".reviewButton").exists()).toBe(false); + }); + + test("hidden while nothing waits for review", () => { + const { wrapper } = mountButton({ pendingPrompts: [] }); + + expect(wrapper.find(".reviewButton").exists()).toBe(false); + }); + + test("counts every comic a series-level prompt covers", () => { + const { wrapper } = mountButton(); + + const button = wrapper.find(".reviewButton"); + expect(button.text()).toContain("Review"); + expect(button.text()).toContain("3"); + expect(button.attributes("aria-label")).toBe( + "Review 3 online tagging matches", + ); + }); + + test("click opens the Match Review dialog", async () => { + const { wrapper, store } = mountButton(); + + await wrapper.find(".reviewButton").trigger("click"); + + expect(store.promptDialogOpen).toBe(true); + }); + + test("phones get the icon and count only", () => { + const { wrapper } = mountButton(); + + const word = wrapper.find(".reviewButton .d-none.d-sm-inline"); + expect(word.exists()).toBe(true); + expect(word.text()).toBe("Review"); + }); +}); From 1ad28f7cbc14f65801a3496f164a5bf60c123126 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Wed, 7 Oct 2026 01:36:15 -0700 Subject: [PATCH 2/2] Add the online-tag Review button to the reader toolbar With the hamburger prompts dot gone, the reader had no passive cue for matches waiting; only the drawer item and the (tab-only) title prefix. Mount OnlineTagReviewButton after the reader toolbar's spacer, as in the browser toolbar. Nothing else in the reader top toolbar can shrink, so below 360px the button pushed the settings button off-screen (a three-digit reading order needs 360px with it, 302px without). Hide it under 360px; the drawer item still offers the review there. Co-Authored-By: Claude Opus 5.5 --- .../src/components/online-tag/review-button.vue | 4 ++-- .../reader/toolbars/top/reader-toolbar-top.vue | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/online-tag/review-button.vue b/frontend/src/components/online-tag/review-button.vue index 2176836a3..52819fe37 100644 --- a/frontend/src/components/online-tag/review-button.vue +++ b/frontend/src/components/online-tag/review-button.vue @@ -1,8 +1,8 @@