---
phase: 12-bulk-sweep-batch-review-queue
plan: 03
type: execute
wave: 3
depends_on: ["12-01", "12-02"]
files_modified:
  - web/src/api/client.ts
  - web/src/api/client.test.ts
  - web/src/routes/Admin.tsx
  - web/src/routes/Admin.test.tsx
  - web/src/components/TagReviewView.tsx
  - web/src/components/TagReviewView.css
  - web/src/components/TagReviewView.test.tsx
  - web/src/routes/AppShell.tsx
  - web/src/App.tsx
autonomous: true
requirements: [TAG-05, TAG-06]
must_haves:
  truths:
    - "An admin can start a tag-suggestion sweep from the admin Settings page (untagged by default, an 'include pages that already have tags' toggle for all), seeing a started/zero-target/error confirmation"
    - "An admin-visible nav entry opens the dedicated /app/tag-review route (lazy-loaded), shown only when role is admin"
    - "The review route lists pages with pending suggestions and reviews one page at a time by REUSING the Phase-11 TagSuggestList approval surface"
    - "Approving a page sends the checked tags to the batched approve endpoint; the page leaves the backlog and the progress line decrements"
    - "A per-page 409 stale revision switches that page into the inherited stale state without breaking the rest of the backlog; empty/loading/error states render per 12-UI-SPEC"
  artifacts:
    - path: "web/src/components/TagReviewView.tsx"
      provides: "The /app/tag-review route shell: backlog list + one-page-at-a-time review (reusing TagSuggestList) + batched Apply approved + progress + empty/loading/error/stale states"
      min_lines: 80
    - path: "web/src/api/client.ts"
      provides: "startTagSweep / listTagSuggestions / approveTagSuggestions api fns"
      contains: "approveTagSuggestions"
  key_links:
    - from: "web/src/components/TagReviewView.tsx"
      to: "web/src/api/client.ts"
      via: "react-query reads listTagSuggestions for the backlog and calls approveTagSuggestions on Apply approved"
      pattern: "listTagSuggestions|approveTagSuggestions"
    - from: "web/src/components/TagReviewView.tsx"
      to: "web/src/components/TagSuggest.tsx"
      via: "per-page review REUSES the Phase-11 TagSuggestList surface (per-tag rows, new-default-unchecked, stale state) — not re-implemented"
      pattern: "TagSuggestList"
    - from: "web/src/routes/AppShell.tsx"
      to: "web/src/App.tsx"
      via: "an admin-gated nav row navigates to the lazy /app/tag-review route"
      pattern: "tag-review"
---

<objective>
Build the frontend for the bulk sweep + batch review queue per 12-UI-SPEC (TAG-05 sweep-start control + TAG-06 review route). Add the admin "Tag suggestions" sweep-start section to Admin.tsx (untagged-default + an "include pages that already have tags" toggle, with started / zero-target / busy / error states — cloning the existing "Rebuild graph index" admin section). Add a lazy `/app/tag-review` route + an admin-visible nav entry (cloning the Phase-10 GraphView lazy-route + nav-row pattern). Build TagReviewView: a backlog list of pages with pending suggestions, reviewed ONE PAGE AT A TIME by REUSING the Phase-11 `TagSuggestList`, with a batched "Apply approved" action, a progress line, and the empty / loading / error / per-page stale states. No new dependency, no new token, no `dangerouslySetInnerHTML`.

Purpose: TAG-05 + TAG-06 — the admin starts the sweep and reviews/approves the queue, both admin-only. The UI reuses the three already-shipped patterns (Admin section, Phase-10 lazy route + nav, Phase-11 TagSuggestList) per the locked UI-SPEC scope guard.
Output: 3 api fns, the Admin sweep section, the lazy review route + nav entry, the TagReviewView component + CSS + tests.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-CONTEXT.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-UI-SPEC.md
@.planning/phases/11-per-page-llm-tag-suggestion/11-03-SUMMARY.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-01-PLAN.md
@.planning/phases/12-bulk-sweep-batch-review-queue/12-02-PLAN.md
</context>

<artifacts_this_phase_produces>
This plan delivers the Wave-3 frontend artifacts of Phase 12:
- `startTagSweep` / `listTagSuggestions` / `approveTagSuggestions` api client fns (the three Wave-1/2 admin endpoints)
- the admin "Tag suggestions" sweep-start section in Admin.tsx (untagged-default + all toggle + states)
- the lazy `/app/tag-review` route + an admin-visible nav entry in AppShell
- `TagReviewView.tsx` + CSS + tests — the backlog list + one-page-at-a-time review (reusing TagSuggestList) + batched Apply approved + progress + states
</artifacts_this_phase_produces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: api fns + admin sweep-start section</name>
  <files>web/src/api/client.ts, web/src/api/client.test.ts, web/src/routes/Admin.tsx, web/src/routes/Admin.test.tsx</files>
  <read_first>
    - web/src/api/client.ts (reindexGraph ~1170, reindexSearch ~1161, suggestTags ~960, applyTags ~974, the mutate()/get() helpers + err.status attach) — the EXACT api-fn shape to clone: an awaited `mutate()` CSRF POST for the sweep-start + approve, and a `get()` for the suggestions list. approveTagSuggestions surfaces a per-page result array (the server returns [{page_path,status}]); the whole call only 409s if the request itself fails — per-page stale comes back as status="stale" in the result array (read 12-02 handler contract).
    - web/src/routes/Admin.tsx (reindexGraphMut ~159, the "Graph" admin-section JSX ~267-289, graphReindexNotice/graphReindexError state ~157) — clone this section VERBATIM for the new "Tag suggestions" section: `.admin-section` + `.admin-section-heading` + `.admin-muted` description + `.admin-section-row` (a `.btn-secondary` button with `Starting…` busy label + a muted `role="status"` confirmation) + `.field-error role="alert"`. Add the scope toggle (a single `<label><input type="checkbox">` defaulting UNCHECKED = "only untagged pages").
    - .planning/phases/12-bulk-sweep-batch-review-queue/12-UI-SPEC.md (Copywriting Contract → "Sweep-start control" table; Component Inventory → "Sweep-start section" + "Scope toggle" rows) — the EXACT copy: heading "Tag suggestions", description, toggle labels, button "Suggest tags for pages" / "Starting…", confirmations ("Started suggesting tags for untagged pages — {n} pages queued for review." / all-scope variant / "Every page already has tags — nothing to suggest." for queued=0), error "Couldn&rsquo;t start the sweep. Try again." Hidden-infra discipline: no job/queue/worker/LLM/git vocabulary.
    - web/src/routes/Admin.test.tsx — the existing admin-section test pattern (mock the client, assert the button + confirmation + error). Mirror it for the sweep section.
  </read_first>
  <behavior>
    - startTagSweep({all}) POSTs to /admin/tags/sweep and returns {queued:number}; the toggle drives `all` (default false).
    - listTagSuggestions() GETs /admin/tags/suggestions and returns the pending-queue array.
    - approveTagSuggestions({approvals:[{page_path,tags}]}) POSTs to /admin/tags/approve and returns [{page_path,status}].
    - The Admin sweep section: clicking "Suggest tags for pages" fires the mutation, shows "Starting…" while pending, then the scope-appropriate confirmation — including the queued=0 "every page already has tags" message — or the generic error line. The toggle picks untagged vs all.
  </behavior>
  <action>
    In `web/src/api/client.ts` add (mirroring reindexGraph + applyTags): `startTagSweep(opts: { all: boolean }): Promise<{ queued: number }>` (awaited `mutate("/api/v1/admin/tags/sweep", { all: opts.all })`); `TagSuggestionEntry { page_path, suggestions: TagSuggestion[], base_revision }` + `listTagSuggestions(): Promise<TagSuggestionEntry[]>` (a `get("/api/v1/admin/tags/suggestions")`); `TagApproveResult { page_path, status: "applied"|"stale"|"notfound" }` + `approveTagSuggestions(approvals: { page_path: string; tags: string[] }[]): Promise<TagApproveResult[]>` (a `mutate("/api/v1/admin/tags/approve", { approvals })`). Reuse the existing `TagSuggestion {tag, existing}` type from 11-03. Extend `client.test.ts`: each fn hits the right route with the right body and returns the typed result; approve returns the per-page status array.

    In `web/src/routes/Admin.tsx` add a new `<section className="admin-section">` (place it directly below the existing "Graph" section per the UI-SPEC mount point). Clone the reindexGraphMut pattern: local state `sweepNotice` / `sweepError` + a `sweepAll` boolean (default false) + `startSweepMut = useMutation({ mutationFn: () => startTagSweep({ all: sweepAll }), onSuccess: (res) => set the scope-appropriate confirmation using res.queued (queued===0 → the "every page already has tags" copy), onError: set the generic error })`. JSX: `.admin-section-heading` "Tag suggestions"; `.admin-muted` description; a `<label>`-wrapped checkbox toggle (`sweepAll`, label switches between "Only untagged pages" unchecked-default and reads "Include pages that already have tags" — implement as a single checkbox labelled "Include pages that already have tags", unchecked = untagged-only per UI-SPEC); the `.admin-section-row` with a `.btn .btn-secondary` button ("Suggest tags for pages" / "Starting…" + disabled while pending) + the muted `role="status"` confirmation; a `.field-error role="alert"` for the error. Use the EXACT UI-SPEC copy (with `&rsquo;` for apostrophes). No new token; the checkbox uses `accent-color: var(--color-accent)`.

    Extend `web/src/routes/Admin.test.tsx`: mock the client; assert the sweep section renders the heading + button; clicking with the toggle off calls startTagSweep({all:false}) and shows the untagged confirmation with the queued count; queued===0 shows the "every page already has tags" line; toggling on calls startTagSweep({all:true}); a rejected mutation shows the generic error line (no infra vocabulary).
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/api/client.test.ts src/routes/Admin.test.tsx</automated>
  </verify>
  <done>startTagSweep/listTagSuggestions/approveTagSuggestions exist and are tested; the Admin "Tag suggestions" section starts a sweep (untagged-default + all toggle) and shows started/zero-target/busy/error states with the exact UI-SPEC copy; `tsc -b` clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: lazy /app/tag-review route + admin nav entry + TagReviewView (backlog + reuse TagSuggestList + batched apply + states)</name>
  <files>web/src/components/TagReviewView.tsx, web/src/components/TagReviewView.css, web/src/components/TagReviewView.test.tsx, web/src/routes/AppShell.tsx, web/src/App.tsx</files>
  <read_first>
    - web/src/App.tsx (the GraphView lazy import ~19 + the /app/graph Route with the Suspense fallback ~117-139) — clone this for a `const TagReviewView = lazy(() => import("./components/TagReviewView"))` + an `/app/tag-review` Route wrapped in AppShell + Suspense (admin-gated; use RequireAdmin like the /admin route ~150-159, OR render-null-for-non-admin inside the component — prefer wrapping in RequireAdmin to match the /admin route since the server is the real gate).
    - web/src/routes/AppShell.tsx (the Graph nav row ~696-706: `.navrow .navrail-trash-row` + `navrail-row-active` on `location.pathname === "/app/graph"` + `aria-current` + lucide `Network` `tree-icon` + `tree-label`; `isAdmin` ~87; the `{isAdmin && ...}` footer ~719) — add a NEW nav row beside the Graph row, gated behind `isAdmin`, navigating to `/app/tag-review`, active when `location.pathname === "/app/tag-review"`, with a lucide `Tags` or `ListChecks` icon + label "Tag review" (UI-SPEC nav copy).
    - web/src/components/TagSuggest.tsx (the `TagSuggestList` approval surface from 11-03: per-tag checkbox rows, "new" badge + new-default-unchecked, Select all/Clear all, `{n} selected` count, Apply NEVER auto-focused / Cancel DOM-first focused, Esc/backdrop cancel, focus trap, the 409 stale state with Re-run) — REUSE this surface VERBATIM per page. Do NOT re-implement the row internals (UI-SPEC scope guard). The ONLY adaptations: the dismiss button reads "Skip for now" (leaves the page in the backlog), the primary CTA reads "Apply approved", and on a successful apply the page leaves the backlog + the progress line decrements. If TagSuggestList is not exported/reusable standalone (it was built coupled to PageEditor's suggest trigger in 11-03), extract the approval-LIST surface into a reusable export that takes (suggestions, baseRevision, onApply, onSkip, busy, stale) props WITHOUT changing its interaction contract, and have BOTH PageEditor's TagSuggest and TagReviewView consume it.
    - .planning/phases/12-bulk-sweep-batch-review-queue/12-UI-SPEC.md (Component Inventory: "Backlog list", "Pending-count badge", "Per-page review surface", "Progress line", "Empty/loading/error states"; the Review-route Copywriting table; the Interaction Contract TAG-06 rows) — the EXACT structure + copy: route title "Tag review", subtitle, progress "{n} pages left to review" (`aria-live="polite"`), backlog row (title text child + muted path + `{n} pending` `.role-badge` chip + `ChevronRight`), primary CTA "Apply approved" (NEVER auto-focused), "Skip for now", empty/loading/error/stale copy. NO `dangerouslySetInnerHTML` on any title/path/tag.
    - web/src/api/client.ts (listTagSuggestions / approveTagSuggestions from Task 1; the TagSuggestionEntry / TagApproveResult types) — the data the view reads + writes.
    - web/src/components/TagSuggest.css + the inherited Dialog/DiffReviewDialog/`.role-badge`/`.admin-empty`/`.agentpanel-loading` classes — TagReviewView.css is token-only and reuses these; NO new token (UI-SPEC Reused Tokens inventory).
  </read_first>
  <behavior>
    - The route is lazy-loaded and admin-gated (RequireAdmin); a non-admin is redirected/blocked (server is the real gate).
    - An admin-visible nav row "Tag review" navigates to /app/tag-review and shows active state (navrail-row-active + aria-current) when on that route.
    - TagReviewView fetches listTagSuggestions via react-query; while loading shows "Loading pages to review…" (spinner); on error shows "Couldn&rsquo;t load the review queue. Refresh to try again."; when empty shows "No pending suggestions" + the body copy.
    - The backlog renders one row per pending page (title text child / falls back to path, muted path, "{n} pending" chip). Selecting a row opens that page's suggestions in the reused TagSuggestList (one page at a time), with a "{n} pages left to review" `aria-live` progress line.
    - "Apply approved" sends the checked tags for THAT page to approveTagSuggestions([{page_path, tags}]); on a status="applied" result the page leaves the backlog (invalidate the queue query) and the progress line decrements; a status="stale" result switches that page into the inherited stale state (warning banner, Apply removed, Re-run/Skip) without breaking the rest of the backlog.
    - "Skip for now" leaves the page in the backlog and writes nothing; Apply is NEVER auto-focused (inherited trust-gate); no dangerouslySetInnerHTML anywhere.
  </behavior>
  <action>
    If needed, refactor `web/src/components/TagSuggest.tsx` to export the reusable approval-list surface (props: suggestions, baseRevision, busy, stale, onApply(checkedTags), onSkip/onCancel, onRerun) preserving the verbatim trust-gate contract; keep PageEditor's existing TagSuggest working by having it consume the same export. Do NOT change the interaction behavior or add a token.

    Create `web/src/components/TagReviewView.tsx`: a full-pane component (default export for the lazy import). Use react-query `useQuery({ queryKey: ["tag-suggestions"], queryFn: listTagSuggestions })` for the backlog and a `useMutation` for approve. State: the currently-open page path (null = no page open, show the backlog). Render: a header bar (`--color-surface`) with title "Tag review" + the muted subtitle; the loading/error/empty states (clone `.agentpanel-loading` / the BacklinksPanel error line / `.admin-empty`); the progress line "{n} pages left to review" (`aria-live="polite"`); the backlog list (rows = title text child + muted path + `{n} pending` `.role-badge` chip + `ChevronRight`, each a keyboard-navigable button at `--hit-min-height`, hover `--color-hover`, the open row selected via `--color-tree-active-bg`); when a row is open, render the reused TagSuggestList for that page's staged suggestions + base_revision, with the dismiss button labelled "Skip for now" and the primary CTA "Apply approved". On Apply: call approveTagSuggestions([{ page_path, tags: checkedTags }]); inspect the returned status — "applied" → invalidate ["tag-suggestions"], close the page, decrement progress; "stale" → set the local stale flag so TagSuggestList shows its inherited stale state (Re-run re-runs suggestTags for that page, or simply re-fetches the queue). On Skip: close the page, leave it pending. NEVER use dangerouslySetInnerHTML — titles/paths/tags are React text children. Use the EXACT UI-SPEC copy with `&rsquo;`.

    Create `web/src/components/TagReviewView.css`: token-only; `@import` the TagSuggest/Dialog shells as needed; backlog rows clone the `.navrow`/BacklinksPanel-row recipe + `.role-badge` chip; the empty state centered with `--space-2xl`. NO new token, NO new dependency.

    In `web/src/App.tsx`: add `const TagReviewView = lazy(() => import("./components/TagReviewView"))` and a `<Route path="/app/tag-review" element={<RequireAdmin><AppShell><Suspense fallback={...}><TagReviewView /></Suspense></AppShell></RequireAdmin>} />` (clone the /app/graph Suspense + the /admin RequireAdmin wrapping). In `web/src/routes/AppShell.tsx`: add the admin-gated "Tag review" nav row beside the Graph row inside `.navrail-body`, wrapped in `{isAdmin && (...)}` (or rendered conditionally on isAdmin), with the `navrail-row-active` + `aria-current` active recipe keyed on `/app/tag-review` and a lucide `Tags`/`ListChecks` icon.

    Create `web/src/components/TagReviewView.test.tsx` (vitest + QueryClientProvider + `vi.mock("../api/client")`, mirror TagSuggest.test.tsx): seed `["me"]` admin role; mock listTagSuggestions to return 2 pending pages. Assert: loading state ("Loading pages to review…"); the backlog renders both rows with title + path + "{n} pending" chip; empty state when listTagSuggestions returns []; error state when it rejects; opening a row shows the reused approval surface with new-default-unchecked + Apply NOT auto-focused; Apply approved calls approveTagSuggestions with exactly the checked tags for that page and, on "applied", removes the page from the backlog + decrements the progress line; a returned "stale" status shows the inherited stale state (warning + Re-run, Apply removed) without affecting the other backlog row; "Skip for now" leaves the page pending (approveTagSuggestions not called); no `dangerouslySetInnerHTML`. Add a small AppShell/App test (or extend an existing nav test) asserting the "Tag review" nav row renders for an admin and is absent for a non-admin, and routes to /app/tag-review.
  </action>
  <verify>
    <automated>cd web && npx tsc -b && npx vitest run src/components/TagReviewView.test.tsx && npx vitest run</automated>
  </verify>
  <done>A lazy /app/tag-review route + an admin-visible "Tag review" nav row exist; TagReviewView lists pending pages and reviews one at a time by REUSING TagSuggestList; Apply approved calls the batched approve endpoint (page leaves backlog + progress decrements); a per-page 409 stale switches that page into the inherited stale state without breaking the backlog; empty/loading/error states render per UI-SPEC; no new token, no new dependency, no dangerouslySetInnerHTML; `tsc -b` + full `vitest run` green.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| rendered page titles / paths / tag names | data from the review-queue endpoint rendered in the browser — a stored-XSS surface if rendered as HTML |
| client → admin endpoints | the sweep-start, list, and approve calls are admin-gated server-side; the client UI gate is convenience only |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-12-11 | Tampering (stored XSS) | TagReviewView rendering titles/paths/tags | mitigate | titles, paths, and tag names render as React text children ONLY — NO `dangerouslySetInnerHTML` anywhere (the locked stored-XSS guard, identical to BacklinksPanel / TagSuggest); asserted by the component test |
| T-12-12 | Elevation of Privilege | the nav entry / route / admin section visibility | mitigate | the nav row + route + admin section render only when `me.role === "admin"` (client convenience); the REAL gate is the server RequireRole(admin) on every endpoint (12-01/12-02). The UI gate never bypasses the server gate |
| T-12-13 | Information Disclosure | hidden-infra leakage in copy/errors | mitigate | every status/empty/error/loading/progress line is a quiet generic muted line with ZERO job/queue/worker/git/sqlite/LLM vocabulary (UI-SPEC hidden-infra invariant) |
| T-12-14 | Tampering | reflexive Apply / accidental write | mitigate | "Apply approved" is NEVER auto-focused; "Skip for now" is the DOM-first focused safe default; Esc/backdrop dismiss without writing (the trust-gate inversion inherited verbatim from TagSuggestList) |
| T-12-SC | Tampering | npm installs | mitigate | NO new dependency this plan (CONTEXT + UI-SPEC lock); `git diff web/package.json web/package-lock.json` must be empty; slopcheck N/A |
</threat_model>

<verification>
- `cd web && npx tsc -b` clean (no type errors).
- `cd web && npx vitest run` — the FULL suite is green, including the new client.test.ts tag-sweep fns, Admin.test.tsx sweep section, and TagReviewView.test.tsx.
- `git diff --stat web/package.json web/package-lock.json` is empty (NO new dependency — UI-SPEC Registry Safety).
- `git diff --stat web/src/styles/tokens.css` is empty (NO new token — UI-SPEC Reused Tokens inventory).
- A grep confirms no `dangerouslySetInnerHTML` in TagReviewView.tsx.
- `CGO_ENABLED=0 go build ./...` at repo root still clean (the embedded SPA builds).
</verification>

<success_criteria>
- An admin can start a tag-suggestion sweep from Admin.tsx (untagged default + "include pages that already have tags" toggle), with started / zero-target / busy / error states using the exact UI-SPEC copy.
- A lazy `/app/tag-review` route + an admin-visible "Tag review" nav entry exist (Phase-10 lazy-route + nav-row pattern; active state + aria-current).
- TagReviewView lists pages with pending suggestions and reviews ONE PAGE AT A TIME by REUSING the Phase-11 TagSuggestList (per-tag rows, new-default-unchecked, trust-gate focus inversion, stale state) — not re-implemented.
- "Apply approved" routes the checked tags through the batched approve endpoint; an applied page leaves the backlog + the progress line decrements; a per-page stale 409 switches that page into the inherited stale state without breaking the rest of the backlog.
- Empty / loading / error states render per UI-SPEC; no new dependency, no new token, no `dangerouslySetInnerHTML`.
</success_criteria>

<output>
Create `.planning/phases/12-bulk-sweep-batch-review-queue/12-03-SUMMARY.md` when done.
</output>