From 05ddbda2da69c9b8c2864c3c7727ca3f97b1e120 Mon Sep 17 00:00:00 2001
From: "Claw (AINYC Agent)"
Date: Mon, 18 May 2026 02:35:20 +0000
Subject: [PATCH] feat(web): "Why this?" panel for content recommendations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an expandable LLM-explanation panel on each content-recommendation
card in the report's action plan. Clicking "Why this?" opens a small panel
that hydrates from the cached explanation if one exists, otherwise auto-
fires POST /analyze to generate one. Backed by the endpoints shipped in
PR #586.
Wiring:
- `apps/web/src/api.ts` — `fetchRecommendationAnalysis()` (returns null
on 404 for clean branching) + `analyzeRecommendation(body)`.
- `apps/web/src/queries/mutations.ts` — `useAnalyzeRecommendation()`.
Deliberately does NOT invalidate any project-scoped queries: the
explanation is per-card and doesn't change the recommendation list.
- `apps/web/src/pages/ReportPage.tsx`:
- New `WhyThisPanel` + `ExplanationBody` components (inline — only
used in one place per AGENTS.md component rule).
- "Why this?" button next to the existing "Mark addressed" button.
- Expand state lives on `ActionPlanSection` as a `Set`,
matching the optimistic-dismiss state pattern.
Panel UX:
- Auto-fetches cached analysis on first open → falls through to POST
if no cache.
- Provider dropdown switches the explainer on the fly (forceRefresh =
true with `provider` override; auto-fires on change for snappier feel).
- Manual "Regenerate" button forces a fresh call.
- Footer renders model id + cost in cents (millicents / 1000).
- Loading + error states; "Try again" affordance on error.
Tests: 9 new — covers cache hit, cache miss → auto-POST, error path,
regenerate, provider switch, hide button. All 3123 workspace tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/web/src/api.ts | 52 +++++-
apps/web/src/pages/ReportPage.tsx | 253 ++++++++++++++++++++++++--
apps/web/src/queries/mutations.ts | 32 +++-
apps/web/test/why-this-panel.test.tsx | 223 +++++++++++++++++++++++
package.json | 2 +-
packages/canonry/package.json | 2 +-
6 files changed, 547 insertions(+), 17 deletions(-)
create mode 100644 apps/web/test/why-this-panel.test.tsx
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 1710ab9f..ecc3b34d 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -1,4 +1,4 @@
-import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, GscPerformanceDailyDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, ProjectReportDto, ReportAudience, CitationVisibilityResponse, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto, TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficStatusResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficConnectWordpressRequest, TrafficConnectVercelRequest, TrafficSyncResponse, DiscoveryRunRequest, DiscoverySessionDto, DiscoverySessionDetailDto, DiscoveryPromotePreview, DiscoveryPromoteRequest, DiscoveryPromoteResult, ProjectDto, QueryDto, CompetitorDto, LocationContext, GoogleConnectionDto, GscUrlInspectionDto, GscDeindexedRowDto, BingUrlInspectionDto, BingCoverageSummaryDto, BingKeywordStatsDto, BingStatusDto, BingConnectResponseDto, BingSetSiteResponseDto, BingSitesResponseDto, GscSearchDataDto, ContentTargetDismissalDto, ContentTargetDismissRequest } from '@ainyc/canonry-contracts'
+import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, GscPerformanceDailyDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, ProjectReportDto, ReportAudience, CitationVisibilityResponse, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto, TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficStatusResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficConnectWordpressRequest, TrafficConnectVercelRequest, TrafficSyncResponse, DiscoveryRunRequest, DiscoverySessionDto, DiscoverySessionDetailDto, DiscoveryPromotePreview, DiscoveryPromoteRequest, DiscoveryPromoteResult, ProjectDto, QueryDto, CompetitorDto, LocationContext, GoogleConnectionDto, GscUrlInspectionDto, GscDeindexedRowDto, BingUrlInspectionDto, BingCoverageSummaryDto, BingKeywordStatsDto, BingStatusDto, BingConnectResponseDto, BingSetSiteResponseDto, BingSitesResponseDto, GscSearchDataDto, ContentTargetDismissalDto, ContentTargetDismissRequest, RecommendationExplanationDto, RecommendationExplainRequest } from '@ainyc/canonry-contracts'
import {
createClient as createHeyClient,
// Projects + queries + competitors + locations + runs + apply + settings + telemetry
@@ -14,6 +14,8 @@ import {
postApiV1ProjectsByNameQueriesGenerate,
postApiV1ProjectsByNameContentDismissals,
deleteApiV1ProjectsByNameContentDismissalsByTargetRef,
+ getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis,
+ postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze,
getApiV1ProjectsByNameCompetitors,
putApiV1ProjectsByNameCompetitors,
postApiV1ProjectsByNameLocations,
@@ -548,6 +550,54 @@ export function undismissContentTarget(projectName: string, targetRef: string):
)
}
+/**
+ * Read-only cache lookup for a recommendation's LLM explanation. Returns
+ * `null` when no cached row exists yet (the GET endpoint 404s in that
+ * case). UI uses this to hydrate the "Why this?" panel without paying for
+ * a fresh LLM call when the user expands a recommendation that was
+ * analyzed in an earlier session.
+ *
+ * Returns `null` (not throws) on 404 so the caller can branch declaratively
+ * — every other error still throws via `invokeWeb`.
+ */
+export async function fetchRecommendationAnalysis(
+ projectName: string,
+ targetRef: string,
+): Promise {
+ try {
+ return await invokeWeb(() =>
+ getApiV1ProjectsByNameContentRecommendationsByTargetRefAnalysis({
+ client: heyClient,
+ path: { name: projectName, targetRef },
+ }),
+ )
+ } catch (err) {
+ if (err instanceof ApiError && err.statusCode === 404) return null
+ throw err
+ }
+}
+
+/**
+ * Generate (or return cached) LLM explanation for one recommendation.
+ * Idempotent on the server: cached rows are returned free for the same
+ * `(project, targetRef, promptVersion)`. Pass `forceRefresh: true` to
+ * regenerate; pass `provider` / `model` to override the project's default
+ * agent provider.
+ */
+export function analyzeRecommendation(
+ projectName: string,
+ targetRef: string,
+ body: RecommendationExplainRequest,
+): Promise {
+ return invokeWeb(() =>
+ postApiV1ProjectsByNameContentRecommendationsByTargetRefAnalyze({
+ client: heyClient,
+ path: { name: projectName, targetRef },
+ body,
+ }),
+ )
+}
+
export function setCompetitors(projectName: string, competitors: string[]): Promise {
return invokeWeb(() =>
putApiV1ProjectsByNameCompetitors({
diff --git a/apps/web/src/pages/ReportPage.tsx b/apps/web/src/pages/ReportPage.tsx
index f0d768e2..a32eb503 100644
--- a/apps/web/src/pages/ReportPage.tsx
+++ b/apps/web/src/pages/ReportPage.tsx
@@ -1,13 +1,15 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react'
import type {
ProjectReportDto,
+ RecommendationExplanationDto,
ReportActionPlanItem,
ReportAudience,
ReportInsight,
} from '@ainyc/canonry-contracts'
import {
+ AGENT_PROVIDER_IDS,
contentActionLabel,
dedupeReportActions,
dedupeReportOpportunities,
@@ -22,10 +24,10 @@ import {
import { ToneBadge } from '../components/shared/ToneBadge.js'
import { Button } from '../components/ui/button.js'
-import { downloadReportHtml, heyClient, ApiError } from '../api.js'
+import { downloadReportHtml, fetchRecommendationAnalysis, heyClient, ApiError } from '../api.js'
import { getApiV1ProjectsByNameReportOptions } from '@ainyc/canonry-api-client/react-query'
import { asyncHandler } from '../lib/async-handler.js'
-import { useDismissContentTarget } from '../queries/mutations.js'
+import { useAnalyzeRecommendation, useDismissContentTarget } from '../queries/mutations.js'
import { addToast } from '../lib/toast-store.js'
import type { MetricTone } from '../view-models.js'
@@ -393,6 +395,200 @@ function clientConfidenceLabel(confidence: ReportActionPlanItem['confidence']):
}
}
+/**
+ * Render an LLM-generated explanation as a bulleted list. The system
+ * prompt forces dash-prefixed bullets (`- first reason\n- action\n- ...`).
+ * Anything that doesn't lead with `-` falls back to a paragraph so a
+ * misbehaving response still renders rather than disappearing.
+ */
+export function ExplanationBody({ text }: { text: string }) {
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
+ const bullets = lines
+ .filter((l) => l.startsWith('-') || l.startsWith('•'))
+ .map((l) => l.replace(/^[-•]\s*/, ''))
+ const paragraphs = lines.filter((l) => !l.startsWith('-') && !l.startsWith('•'))
+ return (
+
+ {paragraphs.map((p, i) => (
+
{p}
+ ))}
+ {bullets.length > 0 && (
+
+ {bullets.map((b, i) => - {b}
)}
+
+ )}
+
+ )
+}
+
+/**
+ * Inline "Why this?" panel — one per action card with a `targetRef`.
+ * On mount: if a cached explanation exists, hydrate from it; otherwise
+ * fire `POST /analyze` immediately. Repeat opens within a session are
+ * cheap because the backend caches per (project, targetRef, promptVersion).
+ *
+ * Provider override: the dropdown lets the operator pick a non-default
+ * provider for this explanation. Switching providers triggers an immediate
+ * regenerate with `forceRefresh: true` so the panel renders the new
+ * provider's output without a second click.
+ *
+ * Cost is rendered in cents (millicents / 1000) to a 4-decimal precision so
+ * even sub-tenth-of-a-cent calls are legible.
+ */
+export function WhyThisPanel({
+ projectName,
+ targetRef,
+ onClose,
+}: {
+ projectName: string
+ targetRef: string
+ onClose: () => void
+}) {
+ const [providerOverride, setProviderOverride] = useState('')
+ const [explanation, setExplanation] = useState(null)
+ const [error, setError] = useState(null)
+ const [isHydrating, setIsHydrating] = useState(true)
+ const analyzeMutation = useAnalyzeRecommendation()
+
+ // Hydrate from the cache on mount. If the GET 404s (no cached
+ // explanation), kick off POST so the panel renders progress immediately.
+ // The provider dropdown isn't applied here — initial hydrate always
+ // pulls whatever's cached for the project's default provider.
+ useEffect(() => {
+ let cancelled = false
+ setIsHydrating(true)
+ setError(null)
+ fetchRecommendationAnalysis(projectName, targetRef)
+ .then((cached) => {
+ if (cancelled) return
+ setIsHydrating(false)
+ if (cached) {
+ setExplanation(cached)
+ return
+ }
+ // No cache — auto-generate so the panel doesn't sit empty.
+ analyzeMutation.mutate(
+ { projectName, targetRef, body: {} },
+ {
+ onSuccess: (data) => {
+ if (!cancelled) setExplanation(data)
+ },
+ onError: (err) => {
+ if (!cancelled) setError(err instanceof Error ? err.message : String(err))
+ },
+ },
+ )
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setIsHydrating(false)
+ setError(err instanceof Error ? err.message : String(err))
+ })
+ return () => {
+ cancelled = true
+ }
+ // Deps are `projectName` + `targetRef` only — re-hydrating on every
+ // mutation state change would re-fire the GET and POST chain on every
+ // keystroke of the provider dropdown. The mutation object is stable
+ // per the useMutation contract for our usage (fire-and-forget mutate
+ // calls from inside the effect).
+ }, [projectName, targetRef])
+
+ const handleRegenerate = (overrideProvider?: string) => {
+ setError(null)
+ const provider = overrideProvider ?? providerOverride
+ analyzeMutation.mutate(
+ {
+ projectName,
+ targetRef,
+ body: {
+ ...(provider ? { provider } : {}),
+ forceRefresh: true,
+ },
+ },
+ {
+ onSuccess: (data) => setExplanation(data),
+ onError: (err) => setError(err instanceof Error ? err.message : String(err)),
+ },
+ )
+ }
+
+ const handleProviderChange = (next: string) => {
+ setProviderOverride(next)
+ // Auto-regenerate when the user picks a new provider — keeps the
+ // dropdown feeling reactive instead of "select then click again."
+ handleRegenerate(next)
+ }
+
+ const isLoading = isHydrating || analyzeMutation.isPending
+ const costCents = explanation ? explanation.costMillicents / 1000 : 0
+
+ return (
+
+
+
Why this recommendation
+
+
+ {isLoading && (
+
+ {isHydrating ? 'Looking for cached analysis…' : 'Analyzing recommendation…'}
+
+ )}
+ {!isLoading && error && (
+
+
{error}
+
+
+ )}
+ {!isLoading && !error && explanation && (
+ <>
+
+
+
+
+
+
+
+
+ {explanation.model} · ~{costCents.toFixed(4)}¢
+
+
+ >
+ )}
+
+ )
+}
+
function ActionPlanSection({ report, audience, projectName }: { report: ProjectReportDto; audience: ReportAudience; projectName: string }) {
const rawActions = audience === 'client'
? report.clientSummary.actionItems
@@ -412,6 +608,10 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR
// dismissed" — the actual server state is whatever the next report
// refetch returns. They converge after a successful round-trip.
const [optimisticDismissed, setOptimisticDismissed] = useState>(new Set())
+ // Set of `targetRef`s whose "Why this?" panel is currently expanded.
+ // Toggling drops/adds the panel — when removed the component unmounts and
+ // its in-flight POST cancels via the cleanup in WhyThisPanel's effect.
+ const [expandedExplanations, setExpandedExplanations] = useState>(new Set())
// Filter dedupedActions through the optimistic set so the UI updates
// instantly. Server-side filter still applies on the next refetch;
// this is purely a render-time bypass to remove perceived latency.
@@ -419,6 +619,15 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR
? dedupedActions.filter(a => !a.targetRef || !optimisticDismissed.has(a.targetRef))
: dedupedActions
+ const toggleExplanation = (targetRef: string) => {
+ setExpandedExplanations((prev) => {
+ const next = new Set(prev)
+ if (next.has(targetRef)) next.delete(targetRef)
+ else next.add(targetRef)
+ return next
+ })
+ }
+
const handleDismiss = (action: ProjectReportDto['actionPlan'][number]) => {
if (!action.targetRef) return
const ref = action.targetRef
@@ -518,16 +727,34 @@ function ActionPlanSection({ report, audience, projectName }: { report: ProjectR
{isClient ? 'What success looks like:' : 'Win condition:'} {action.successMetric}
{action.targetRef && (
-
-
-
+ <>
+
+
+
+
+ {expandedExplanations.has(action.targetRef) && (
+ toggleExplanation(action.targetRef!)}
+ />
+ )}
+ >
)}
)
diff --git a/apps/web/src/queries/mutations.ts b/apps/web/src/queries/mutations.ts
index 01bf93df..b1843610 100644
--- a/apps/web/src/queries/mutations.ts
+++ b/apps/web/src/queries/mutations.ts
@@ -9,6 +9,7 @@ import {
heyClient,
type ApiRun,
type ApiTriggerAllRunsResult,
+ analyzeRecommendation,
appendQueries,
dismissContentTarget,
triggerRun,
@@ -18,7 +19,11 @@ import {
triggerInspectSitemap,
undismissContentTarget,
} from '../api.js'
-import type { ContentTargetDismissRequest } from '@ainyc/canonry-contracts'
+import type {
+ ContentTargetDismissRequest,
+ RecommendationExplainRequest,
+ RecommendationExplanationDto,
+} from '@ainyc/canonry-contracts'
import { createTrackedBatch, trackRun, type TrackedRunSourceAction } from '../lib/run-tracker-store.js'
import { addToast } from '../lib/toast-store.js'
import { invalidateQueriesForRunKind } from './run-invalidations.js'
@@ -350,3 +355,28 @@ export function useUndismissContentTarget() {
},
})
}
+
+/**
+ * Generate (or fetch cached) LLM explanation for one content recommendation.
+ * The backend caches per `(project, targetRef, promptVersion)` — repeat
+ * calls without `forceRefresh` return the cached row free, so the natural
+ * pattern is "fire on panel open, render `mutation.data`."
+ *
+ * We deliberately do NOT invalidate any project-scoped queries here: the
+ * explanation is per-card and does not change the recommendation list,
+ * health scores, or the report DTO. Pulling on those caches would churn
+ * unrelated UI for no reason.
+ *
+ * The mutation's return value is the freshly-fetched explanation; callers
+ * keep it locally (e.g. `mutation.data`) and re-render the panel from it.
+ */
+export function useAnalyzeRecommendation() {
+ return useMutation<
+ RecommendationExplanationDto,
+ Error,
+ { projectName: string; targetRef: string; body: RecommendationExplainRequest }
+ >({
+ mutationFn: ({ projectName, targetRef, body }) =>
+ analyzeRecommendation(projectName, targetRef, body),
+ })
+}
diff --git a/apps/web/test/why-this-panel.test.tsx b/apps/web/test/why-this-panel.test.tsx
new file mode 100644
index 00000000..72120fe1
--- /dev/null
+++ b/apps/web/test/why-this-panel.test.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { afterEach, expect, test, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+import { ExplanationBody, WhyThisPanel } from '../src/pages/ReportPage.js'
+import { mockFetch, jsonResponse, pathOf } from './mock-fetch.js'
+
+afterEach(cleanup)
+
+function renderPanel(targetRef = 'tgt_abc123') {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ const onClose = vi.fn()
+ return {
+ onClose,
+ ...render(
+
+
+ ,
+ ),
+ }
+}
+
+// ─── ExplanationBody ────────────────────────────────────────────────────────
+
+test('ExplanationBody renders dash-prefixed lines as a bulleted list', () => {
+ render()
+ const items = screen.getAllByRole('listitem')
+ expect(items).toHaveLength(3)
+ expect(items[0]!.textContent).toBe('Competitors cited')
+ expect(items[2]!.textContent).toBe('Win citations')
+})
+
+test('ExplanationBody falls back to paragraphs when no bullets are present', () => {
+ render()
+ expect(screen.queryByRole('listitem')).toBeNull()
+ expect(screen.getByText('Plain explanation paragraph.')).toBeTruthy()
+})
+
+test('ExplanationBody renders bullets + paragraphs together', () => {
+ render()
+ expect(screen.getByText('Some context paragraph.')).toBeTruthy()
+ expect(screen.getAllByRole('listitem')).toHaveLength(2)
+})
+
+// ─── WhyThisPanel ───────────────────────────────────────────────────────────
+
+const cachedResponse = {
+ targetRef: 'tgt_abc123',
+ promptVersion: 'v1',
+ provider: 'claude',
+ model: 'claude-sonnet-4-6',
+ responseText: '- Cached reason\n- Cached action\n- Cached outcome',
+ costMillicents: 42,
+ generatedAt: '2026-05-18T01:23:45.000Z',
+}
+
+const freshResponse = {
+ targetRef: 'tgt_abc123',
+ promptVersion: 'v1',
+ provider: 'gemini',
+ model: 'gemini-2.5-flash',
+ responseText: '- Fresh reason\n- Fresh action\n- Fresh outcome',
+ costMillicents: 7,
+ generatedAt: '2026-05-18T01:24:00.000Z',
+}
+
+test('hydrates from cached analysis when GET returns 200', async () => {
+ const restoreFetch = mockFetch((url) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse(cachedResponse)
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ renderPanel()
+ await waitFor(() => {
+ expect(screen.getByText('Cached reason')).toBeTruthy()
+ })
+ // Should not have triggered POST — cache was warm.
+ expect(screen.queryByText('Analyzing recommendation…')).toBeNull()
+ // Footer should show provider + model + cost (in cents).
+ expect(screen.getByText(/claude-sonnet-4-6/)).toBeTruthy()
+ // 42 millicents / 1000 = 0.042 cents → renders as ~0.0420¢
+ expect(screen.getByText(/0\.0420¢/)).toBeTruthy()
+ } finally {
+ restoreFetch()
+ }
+})
+
+test('falls through to POST analyze when GET 404s', async () => {
+ let postCount = 0
+ const restoreFetch = mockFetch((url, init) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse({ error: { code: 'NOT_FOUND', message: 'none' } }, 404)
+ }
+ if (
+ path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze')
+ && init?.method === 'POST'
+ ) {
+ postCount++
+ return jsonResponse(cachedResponse)
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ renderPanel()
+ await waitFor(() => {
+ expect(screen.getByText('Cached reason')).toBeTruthy()
+ })
+ expect(postCount).toBe(1)
+ } finally {
+ restoreFetch()
+ }
+})
+
+test('renders error state when analyze fails after a GET 404', async () => {
+ const restoreFetch = mockFetch((url, init) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse({ error: { code: 'NOT_FOUND', message: 'none' } }, 404)
+ }
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') {
+ return jsonResponse(
+ { error: { code: 'PROVIDER_ERROR', message: 'No provider configured' } },
+ 502,
+ )
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ renderPanel()
+ await waitFor(() => {
+ // The error toast text comes from ApiError.message which formats as
+ // ": " via the global handler. Match the substring.
+ expect(screen.getByText(/No provider configured/i)).toBeTruthy()
+ })
+ expect(screen.getByText(/Try again/i)).toBeTruthy()
+ } finally {
+ restoreFetch()
+ }
+})
+
+test('regenerate button forces a fresh POST with forceRefresh=true', async () => {
+ const analyzeBodies: string[] = []
+ const restoreFetch = mockFetch((url, init) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse(cachedResponse)
+ }
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') {
+ analyzeBodies.push(typeof init.body === 'string' ? init.body : '')
+ return jsonResponse(freshResponse)
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ renderPanel()
+ await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy())
+ const regenerate = screen.getByRole('button', { name: /Regenerate/i })
+ await act(async () => {
+ fireEvent.click(regenerate)
+ })
+ await waitFor(() => expect(screen.getByText('Fresh reason')).toBeTruthy())
+ expect(analyzeBodies).toHaveLength(1)
+ const body = JSON.parse(analyzeBodies[0]!) as { forceRefresh?: boolean; provider?: string }
+ expect(body.forceRefresh).toBe(true)
+ expect(body.provider).toBeUndefined() // no override picked
+ } finally {
+ restoreFetch()
+ }
+})
+
+test('changing the provider dropdown triggers an immediate regenerate with that provider', async () => {
+ let lastBody: { forceRefresh?: boolean; provider?: string } | null = null
+ const restoreFetch = mockFetch((url, init) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse(cachedResponse)
+ }
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analyze') && init?.method === 'POST') {
+ lastBody = JSON.parse(typeof init.body === 'string' ? init.body : '{}')
+ return jsonResponse({ ...freshResponse, provider: 'openai', model: 'gpt-5-mini' })
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ renderPanel()
+ await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy())
+ const select = screen.getByLabelText(/Provider/i) as HTMLSelectElement
+ await act(async () => {
+ fireEvent.change(select, { target: { value: 'openai' } })
+ })
+ await waitFor(() => expect(screen.getByText('Fresh reason')).toBeTruthy())
+ expect(lastBody).not.toBeNull()
+ expect(lastBody!.provider).toBe('openai')
+ expect(lastBody!.forceRefresh).toBe(true)
+ } finally {
+ restoreFetch()
+ }
+})
+
+test('Hide button calls onClose', async () => {
+ const restoreFetch = mockFetch((url) => {
+ const path = pathOf(url)
+ if (path.endsWith('/projects/acme/content/recommendations/tgt_abc123/analysis')) {
+ return jsonResponse(cachedResponse)
+ }
+ throw new Error(`unexpected fetch: ${path}`)
+ })
+ try {
+ const { onClose } = renderPanel()
+ await waitFor(() => expect(screen.getByText('Cached reason')).toBeTruthy())
+ fireEvent.click(screen.getByRole('button', { name: /^Hide$/ }))
+ expect(onClose).toHaveBeenCalled()
+ } finally {
+ restoreFetch()
+ }
+})
diff --git a/package.json b/package.json
index e5daf7de..f63fca80 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "canonry",
"private": true,
- "version": "4.51.0",
+ "version": "4.52.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
diff --git a/packages/canonry/package.json b/packages/canonry/package.json
index 47bc885f..6de330d9 100644
--- a/packages/canonry/package.json
+++ b/packages/canonry/package.json
@@ -1,6 +1,6 @@
{
"name": "@ainyc/canonry",
- "version": "4.51.0",
+ "version": "4.52.0",
"type": "module",
"description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain",
"license": "FSL-1.1-ALv2",