---
phase: 23-llm-tag-suggestions
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [web/src/components/TagSuggest.tsx, web/src/components/TagSuggest.test.tsx, web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx]
autonomous: true
requirements: [ED-03a]

must_haves:
  truths:
    - "A per-page Suggest-tags affordance is rendered in PageEditor near TagBadgeBar (editor-gated)"
    - "Suggestions are de-duped against union(inlineTagsFromBody(body), readTags(frontmatter)) case-insensitively"
    - "Approving applies via the shipped server apply-tags path (server re-validates + writes frontmatter byte-stably)"
    - "After apply, PageEditor reconciles: getPage -> merge applied tags into local frontmatter -> advance baseRevision -> set lastSavedFrontmatter"
    - "Apply then keep typing the body: the next autosave does NOT 409 and does NOT revert the applied tags"
    - "Unsaved local frontmatter edits (e.g. a typed title) are NOT lost by the reconciliation"
    - "Approved tags land ONLY in frontmatter (never inserted as #tag into prose) and render in TagBadgeBar"
    - "A suggestion equal (case-insensitive) to an inline #tag or a frontmatter tag is not offered"
    - "A charset-invalid approved tag is rejected server-side (no backend change — re-validation already shipped)"
    - "Phase 23 is web/-only — no Go/server change"
---

<objective>
Wire the shipped LLM tag-suggestion flow into the live editor: re-home a per-page "Suggest tags"
affordance (lost when Phase 22 deleted InlineTagEditor), de-dup suggestions against the union of
inline + frontmatter tags, and apply via the server apply-tags path WITH an autosave reconciliation
so applying never 409-thrashes, reverts, or loses unsaved edits. Establishes the suggest→approve→
server-apply→reconcile pattern Phase 24 reuses for the description.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md §ED-03a
@.planning/phases/23-llm-tag-suggestions/23-CONTEXT.md
</context>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: TagSuggest — union de-dup + onApplied callback + body/frontmatter props</name>
  <files>web/src/components/TagSuggest.tsx, web/src/components/TagSuggest.test.tsx</files>
  <read_first>
    - web/src/components/TagSuggest.tsx — current props ({ pagePath }), suggestMutation.onSuccess (50–61), applyMutation (64–79), onApply (91–95)
    - web/src/lib/cm/hashtag.ts — inlineTagsFromBody; web/src/lib/tag-frontmatter.ts — readTags
  </read_first>
  <behavior>
    Extend the signature to `TagSuggest({ pagePath, body, frontmatter, onApplied }: { pagePath: string;
    body: string; frontmatter: string; onApplied?: (appliedTags: string[]) => void })`.
    - **Union de-dup (criterion 3):** compute `onPage = new Set([...inlineTagsFromBody(body),
      ...readTags(frontmatter)].map(t => t.toLowerCase()))`. In `suggestMutation.onSuccess`, filter
      `res.suggestions` to those whose `tag.toLowerCase()` is NOT in `onPage` before setState. If the
      filtered list is empty, open the surface in a quiet "No new tags to suggest" state (or skip
      opening and show that status) — never auto-write. Keep the server `existing` flag for the
      "new" badge (a separate axis). Recompute `onPage` from the CURRENT props at suggest time.
    - **onApplied:** in `applyMutation.onSuccess`, REPLACE the bare `invalidateQueries(["page"])`
      (which cannot re-seed the editor past its `seeded` guard) with `onApplied?.(appliedTags)` where
      `appliedTags` is the applied (checked) set captured in `onApply`. The editor owns the
      reconciliation (Task 2). Keep `setOpen(false)`. Leave the 409→stale / error handling intact.
  </behavior>
  <verify>
    `TagSuggest.test.tsx`: given `body="#inline"` + frontmatter tags `[fmtag]`, a suggest returning
    `[inline, fmtag, fresh]` shows ONLY `fresh` (the two on-page tags filtered out); approving calls
    `onApplied(["fresh"])`; an all-on-page result shows the quiet no-new-tags state and never calls
    applyTags. Existing suggest/apply/stale tests still pass (update props).
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: PageEditor — render TagSuggest + apply→reconcile (autosave-safe)</name>
  <files>web/src/routes/PageEditor.tsx, web/src/routes/PageEditor.test.tsx</files>
  <read_first>
    - web/src/routes/PageEditor.tsx — the TagBadgeBar region (~644); refs frontmatter/frontmatterRef/
      baseRevision/lastSavedFrontmatter/lastSavedBody (121–135); the getPage + advance pattern in
      runSaver (276–296); setTags/readTags imports (16–17); scheduleAutosave
    - web/src/components/TagSuggest.tsx — the new props/onApplied (Task 1)
  </read_first>
  <behavior>
    Render `<TagSuggest pagePath={path} body={body} frontmatter={frontmatter}
    onApplied={reconcileAppliedTags} />` next to `TagBadgeBar` (editor-gated by TagSuggest itself).
    Implement `reconcileAppliedTags` (the load-bearing piece — the ED-01b pattern this phase
    establishes):
    ```ts
    const reconcileAppliedTags = useCallback(async (appliedTags: string[]) => {
      let fresh: Awaited<ReturnType<typeof getPage>>;
      try { fresh = await getPage(path); }
      catch { setSaveError("We couldn't refresh tags just now. Your changes are kept here."); return; }
      // Merge applied tags into LOCAL frontmatter (preserve unsaved frontmatter edits — no data loss).
      const merged = setTags(
        frontmatterRef.current,
        unionCI(readTags(frontmatterRef.current), appliedTags),  // case-insensitive union, normalized
      );
      setFrontmatter(merged);
      frontmatterRef.current = merged;
      baseRevision.current = fresh.revision;                 // advance past the apply write -> no false 409
      lastSavedFrontmatter.current = fresh.frontmatter;      // what the server now holds
      // If the local merge diverges from the server bytes (unsaved edits, or format), let autosave
      // converge cleanly against the fresh revision (no 409, no revert).
      if (merged !== fresh.frontmatter) scheduleAutosave();
    }, [path]);
    ```
    `unionCI` = de-dupe case-insensitively, trim, preserve existing order then append new. Do NOT
    overwrite `frontmatterRef`/`frontmatter` with `fresh.frontmatter` wholesale (that would drop a
    user's unsaved title — the seeded-guard/stale-frontmatter contention the red-team flagged). Do
    NOT touch `lastSavedBody`/`bodyRef` (apply did not change the body).
  </behavior>
  <verify>
    `PageEditor.test.tsx` (mock suggestTags/applyTags/getPage/savePage): drive suggest→approve and
    assert (a) the applied tag is in `frontmatter` + rendered by TagBadgeBar (non-inline → has ✕);
    (b) typing in the body AFTER apply triggers an autosave that calls savePage with
    base_revision === the post-apply fresh revision and does NOT 409 (no conflict dialog); (c) a
    title typed-but-unsaved BEFORE apply is still present in the saved frontmatter afterwards (no
    data loss). Reuse the existing PageEditor save-test mocks/harness.
  </verify>
</task>

<task type="implement" tdd="false">
  <name>Task 3: confirm backend unchanged + pin server charset re-validation</name>
  <files>web/src/components/TagSuggest.test.tsx</files>
  <read_first>
    - internal/server/handlers_agent.go — handleApplyTags ValidateTags + ValidateTagCharset (742–758), 204 on success
  </read_first>
  <behavior>
    Verify Phase 23 needs NO Go change (the suggest mode, apply-tags write, re-validation, audit, and
    409 floor are shipped). If the build/tests reveal a real backend gap (e.g. the suggest prompt
    re-offers on-page tags AND the client union filter is insufficient), STOP → EXECUTE BLOCKED to
    planning (do not silently edit Go). Pin criterion 4 at the client boundary: a test that the apply
    surface only sends the CHECKED tags and surfaces the server's rejection (the 422/400 path) for an
    invalid tag without writing — the client never trusts its own list; the server is the authority.
  </behavior>
  <verify>`git diff --stat` shows web/ only; `grep -rn 'apply-tags\|SuggestTags' internal` unchanged from HEAD.</verify>
</task>

</tasks>

<success_criteria>
1. "Suggest tags" in the editor returns vocabulary-biased, capped suggestions for review; nothing
   writes until the user approves (the shipped suggest→review→apply surface, re-homed).
2. Approving applies via the server apply-tags path; the tag lands in frontmatter `tags:`
   (byte-stable, server-revalidated) and renders in `TagBadgeBar`; the body is never touched.
3. A suggestion equal (case-insensitive) to an existing inline `#tag` OR frontmatter tag is filtered
   out (union de-dup over the LIVE body + frontmatter, covering unsaved inline tags).
4. A suggestion failing tag-charset validation is rejected server-side (no backend change; pinned).
5. **Autosave-safe (the hard bar):** approve tags → keep typing the body → the next autosave neither
   409s into the conflict dialog nor reverts the applied tags; an unsaved title is not lost. Verified
   by the PageEditor integration test. `npx vitest run` (from web/) and `npm run build` both pass.
</success_criteria>
