diff --git a/.github/scripts/audit-app-zone-shell.mjs b/.github/scripts/audit-app-zone-shell.mjs index 1964e806a..b57bed2ef 100644 --- a/.github/scripts/audit-app-zone-shell.mjs +++ b/.github/scripts/audit-app-zone-shell.mjs @@ -22,6 +22,9 @@ const SKIP_IF_DESTINATION_CONTAINS = [ "/api/", "/robots.txt", "/sitemap.xml", + // UK chat assistant — the chat app does not yet render the PolicyEngine + // site shell. Skip until the chat repo ships its own shell-compliant header. + "policyengine-uk-chat.vercel.app", ]; const ERROR_PATTERNS = [ @@ -97,7 +100,9 @@ export function extractRoutes(source) { /deepDestination:\s*"([^"]+)"/, )?.[1]; if (!sourcePath) continue; - if (SKIP_IF_DESTINATION_CONTAINS.some((part) => destination.includes(part))) { + if ( + SKIP_IF_DESTINATION_CONTAINS.some((part) => destination.includes(part)) + ) { continue; } @@ -284,9 +289,13 @@ async function discoverSitemapRoutes( allowDestinationFallback, maxSitemapRoutes, ) { - const sitemapUrls = [appendPath(resolveUrl(baseUrl, route.source), "/sitemap.xml")]; + const sitemapUrls = [ + appendPath(resolveUrl(baseUrl, route.source), "/sitemap.xml"), + ]; if (allowDestinationFallback) { - sitemapUrls.push(appendPath(resolveUrl(baseUrl, route.destination), "/sitemap.xml")); + sitemapUrls.push( + appendPath(resolveUrl(baseUrl, route.destination), "/sitemap.xml"), + ); } const discovered = new Map(); @@ -297,7 +306,8 @@ async function discoverSitemapRoutes( for (const loc of extractSitemapLocs(xml)) { const source = sourcePathFromSitemapLoc(loc, route, baseUrl); - if (!source || source === route.source || discovered.has(source)) continue; + if (!source || source === route.source || discovered.has(source)) + continue; discovered.set(source, { source, @@ -371,7 +381,11 @@ export function inspectTopShellData( continue; } - parts.push(element.textContent ?? "", element.ariaLabel ?? "", element.alt ?? ""); + parts.push( + element.textContent ?? "", + element.ariaLabel ?? "", + element.alt ?? "", + ); } const text = parts.join("\n").replace(/\s+/g, " ").trim(); @@ -419,10 +433,16 @@ async function inspectShell(page, url, timeout) { waitUntil: "domcontentloaded", timeout, }); - await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {}); + await page + .waitForLoadState("networkidle", { timeout: 10000 }) + .catch(() => {}); await page.waitForTimeout(500); } catch (error) { - return { ok: false, status: null, reason: `navigation failed: ${error.message}` }; + return { + ok: false, + status: null, + reason: `navigation failed: ${error.message}`, + }; } const status = response?.status() ?? null; @@ -430,9 +450,10 @@ async function inspectShell(page, url, timeout) { return { ok: false, status, reason: `HTTP ${status}` }; } - const bodyText = await page.locator("body").innerText({ timeout: 5000 }).catch( - () => "", - ); + const bodyText = await page + .locator("body") + .innerText({ timeout: 5000 }) + .catch(() => ""); if (bodyText.trim().length < 50) { return { ok: false, status, reason: "empty or nearly empty page" }; } @@ -469,7 +490,13 @@ async function inspectShell(page, url, timeout) { return { ok: true, status, reason: "PolicyEngine shell present" }; } -async function auditRoute(browser, route, baseUrl, timeout, allowDestinationFallback) { +async function auditRoute( + browser, + route, + baseUrl, + timeout, + allowDestinationFallback, +) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, userAgent: "policyengine-app-zone-shell-audit/1.0", @@ -506,9 +533,8 @@ async function runWithConcurrency(items, concurrency, worker) { } await Promise.all( - Array.from( - { length: Math.min(concurrency, items.length) }, - () => runWorker(), + Array.from({ length: Math.min(concurrency, items.length) }, () => + runWorker(), ), ); return results; @@ -551,7 +577,9 @@ export async function main(argv = process.argv.slice(2), env = process.env) { maxSitemapRoutes, ); - console.log(`Auditing ${routes.length} app-zone route(s) for PolicyEngine shell.`); + console.log( + `Auditing ${routes.length} app-zone route(s) for PolicyEngine shell.`, + ); console.log(`Base URL: ${baseUrl}\n`); if (routes.length > baseRoutes.length) { console.log( @@ -561,32 +589,40 @@ export async function main(argv = process.argv.slice(2), env = process.env) { const { chromium } = await import("playwright"); const browser = await chromium.launch(); - const results = await runWithConcurrency(routes, concurrency, async (route) => { - const result = await auditRoute( - browser, - route, - baseUrl, - timeout, - allowDestinationFallback, - ); - const mark = result.ok ? "OK" : "FAIL"; - console.log(`${mark} ${result.source}`); - console.log(` ${result.reason}`); - if (result.usedFallback) { - console.log(" tested destination directly because source returned 404"); - } - if (result.discoveredFromSitemap) { - console.log(` discovered from ${result.discoveredFromSitemap}`); - } - if (result.testedUrl !== result.sourceUrl) { - console.log(` tested ${result.testedUrl}`); - } - return result; - }); + const results = await runWithConcurrency( + routes, + concurrency, + async (route) => { + const result = await auditRoute( + browser, + route, + baseUrl, + timeout, + allowDestinationFallback, + ); + const mark = result.ok ? "OK" : "FAIL"; + console.log(`${mark} ${result.source}`); + console.log(` ${result.reason}`); + if (result.usedFallback) { + console.log( + " tested destination directly because source returned 404", + ); + } + if (result.discoveredFromSitemap) { + console.log(` discovered from ${result.discoveredFromSitemap}`); + } + if (result.testedUrl !== result.sourceUrl) { + console.log(` tested ${result.testedUrl}`); + } + return result; + }, + ); await browser.close(); const failures = results.filter((result) => !result.ok); - console.log(`\n${results.length - failures.length}/${results.length} app-zone routes have the PolicyEngine shell.`); + console.log( + `\n${results.length - failures.length}/${results.length} app-zone routes have the PolicyEngine shell.`, + ); if (failures.length > 0) { console.error("\nRoutes missing the PolicyEngine shell:"); @@ -595,8 +631,12 @@ export async function main(argv = process.argv.slice(2), env = process.env) { console.error(` source: ${failure.sourceUrl}`); console.error(` destination: ${failure.destination}`); } - console.error("\nChild apps served through policyengine.org should render the"); - console.error("PolicyEngine header/nav themselves. Multizone rewrites do not"); + console.error( + "\nChild apps served through policyengine.org should render the", + ); + console.error( + "PolicyEngine header/nav themselves. Multizone rewrites do not", + ); console.error("inject the parent app shell into the child response."); return 1; } diff --git a/app/src/components/chat/AskChatCta.tsx b/app/src/components/chat/AskChatCta.tsx new file mode 100644 index 000000000..4c6c00fbc --- /dev/null +++ b/app/src/components/chat/AskChatCta.tsx @@ -0,0 +1,96 @@ +/** + * AskChatCta — attention-grabbing banner that routes users to the standalone + * chat surface (alternative positioning of policyengine-uk-chat). + * + * Designed to sit above the IngredientReadView on the Reports page. The visual + * treatment (tinted background, sparkle icon, "New" badge) is meant to draw + * attention without competing with the primary "Create report" button — they + * occupy different rows and tell different stories. + */ +import { IconArrowRight, IconSparkles } from '@tabler/icons-react'; +import { Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +interface AskChatCtaProps { + onClick: () => void; +} + +export function AskChatCta({ onClick }: AskChatCtaProps) { + return ( + + + + + + + + + Or just ask + + + New + + + + Skip the builder and ask a policy question in plain English. The assistant runs the + simulation for you. + + + + + + ); +} diff --git a/app/src/components/report/ChatDrawer.tsx b/app/src/components/report/ChatDrawer.tsx new file mode 100644 index 000000000..3ef07f875 --- /dev/null +++ b/app/src/components/report/ChatDrawer.tsx @@ -0,0 +1,41 @@ +/** + * ChatDrawer — slide-out panel that embeds policyengine-uk-chat as an iframe, + * seeded with the report scenario the user is currently viewing. + * + * The chat reads `?scenario_context=` from its URL and forwards it to its + * /chat/message backend, so the assistant knows what the user just saw + * without us having to copy/parse report data into a structured payload. + */ +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { buildChatUrl, CHAT_IFRAME_SANDBOX } from '@/utils/chatUrl'; + +interface ChatDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Plain-English summary of the scenario + headline figures. Forwarded + * to the chat as system-prompt context. */ + scenarioContext: string; +} + +export function ChatDrawer({ open, onOpenChange, scenarioContext }: ChatDrawerProps) { + return ( + + + + Ask a follow-up + + {open && ( + + )} + + + ); +} diff --git a/app/src/components/report/ReportActionButtons.tsx b/app/src/components/report/ReportActionButtons.tsx index b9cbe57c8..53ca585dc 100644 --- a/app/src/components/report/ReportActionButtons.tsx +++ b/app/src/components/report/ReportActionButtons.tsx @@ -1,4 +1,4 @@ -import { IconBookmark, IconCode, IconSettings } from '@tabler/icons-react'; +import { IconBookmark, IconCode, IconMessageCircle, IconSettings } from '@tabler/icons-react'; import { Group } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -10,6 +10,7 @@ interface ReportActionButtonsProps { onSave?: () => void; onView?: () => void; onReproduce?: () => void; + onAskFollowUp?: () => void; } /** @@ -25,6 +26,7 @@ export function ReportActionButtons({ onSave, onView, onReproduce, + onAskFollowUp, }: ReportActionButtonsProps) { return ( @@ -68,6 +70,21 @@ export function ReportActionButtons({ Reproduce in Python )} + {onAskFollowUp && ( + + + + + + + Ask a follow-up + + )} ); diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index 9e76ae829..4d7575645 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -301,6 +301,26 @@ export default function ReportOutputPage({ (window as any).__journeyProfiler?.markEvent('report-output-ready', 'render'); } + // Compose the scenario context the chat drawer will pass to uk-chat. Only + // built for UK reports since the chat backend doesn't model US policy. + // Kept lean for v1 — just the identifying facts. Headline numbers are not + // included yet; the chat re-computes on demand. + const chatScenarioContext = + countryId === 'uk' && report + ? [ + 'The user is viewing a PolicyEngine report.', + `Country: UK`, + report.year ? `Year: ${report.year}` : null, + displayLabel ? `Report label: ${displayLabel}` : null, + versionMetadata?.modelVersion ? `Model version: ${versionMetadata.modelVersion}` : null, + versionMetadata?.dataVersion ? `Data version: ${versionMetadata.dataVersion}` : null, + '', + "Use this as background for the user's follow-up question. If they ask about figures already shown in the report, quote them; otherwise run a fresh simulation against the same year and country.", + ] + .filter(Boolean) + .join('\n') + : undefined; + return ( {saveResult && ( @@ -330,6 +350,7 @@ export default function ReportOutputPage({ onSave={handleSave} onView={handleView} onReproduce={handleReproduce} + chatScenarioContext={chatScenarioContext} > ( diff --git a/app/src/pages/Reports.page.tsx b/app/src/pages/Reports.page.tsx index 9b130993c..c37fb7cf5 100644 --- a/app/src/pages/Reports.page.tsx +++ b/app/src/pages/Reports.page.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { IconSettings } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; +import { AskChatCta } from '@/components/chat/AskChatCta'; import { BulletsValue, ColumnConfig, @@ -13,7 +14,7 @@ import IngredientReadView from '@/components/IngredientReadView'; import { MultiSimOutputTypeCell } from '@/components/report/MultiSimReportOutputTypeCell'; import { ReportOutputTypeCell } from '@/components/report/ReportOutputTypeCell'; import { Stack } from '@/components/ui'; -import { MOCK_USER_ID } from '@/constants'; +import { MOCK_USER_ID, WEBSITE_URL } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useDisclosure } from '@/hooks/useDisclosure'; @@ -52,6 +53,15 @@ export default function ReportsPage() { nav.push(targetPath); }; + const handleOpenChat = () => { + // Chat is served as a multizone child on the website host (policyengine.org/uk/chat) + // via a Vercel rewrite, not within the calculator app. Use a real cross-origin + // navigation rather than the calculator app's router. + if (typeof window !== 'undefined') { + window.location.href = `${WEBSITE_URL}/${countryId}/chat`; + } + }; + const handleCloseRename = () => { closeRename(); setRenamingReportId(null); @@ -209,6 +219,7 @@ export default function ReportsPage() { return ( <> + {countryId === 'uk' && } void; onView?: () => void; onReproduce?: () => void; + /** Plain-English summary of the scenario and headline figures shown in + * this report. When provided, an "Ask a follow-up" button opens a chat + * drawer seeded with this context. Omit to hide the button entirely. */ + chatScenarioContext?: string; children: React.ReactNode; } @@ -46,8 +52,10 @@ export default function ReportOutputLayout({ onSave, onView, onReproduce, + chatScenarioContext, children, }: ReportOutputLayoutProps) { + const [chatOpen, setChatOpen] = useState(false); return ( @@ -85,6 +93,7 @@ export default function ReportOutputLayout({ onSave={onSave} onView={onView} onReproduce={onReproduce} + onAskFollowUp={chatScenarioContext ? () => setChatOpen(true) : undefined} /> @@ -116,6 +125,13 @@ export default function ReportOutputLayout({ {/* Content */} {children} + {chatScenarioContext && ( + + )} ); } diff --git a/app/src/utils/chatUrl.ts b/app/src/utils/chatUrl.ts new file mode 100644 index 000000000..df25aa1ac --- /dev/null +++ b/app/src/utils/chatUrl.ts @@ -0,0 +1,42 @@ +/** + * Shared URL builder for embedding policyengine-uk-chat. + * + * Used by both the supplement surface (ChatDrawer on a report page) and the + * alternative surface (the standalone /chat page). Centralising it here means + * one place to swap the preview URL, add params, or change the bypass flow. + */ + +const CHAT_ORIGIN = + process.env.NEXT_PUBLIC_UK_CHAT_ORIGIN || 'https://policyengine-uk-chat.vercel.app'; + +// Vercel "Protection Bypass for Automation" secret — only needed when the +// iframe targets a protected preview deployment. Production chat is public, +// so this is empty there and the param is omitted. +const CHAT_BYPASS_TOKEN = process.env.NEXT_PUBLIC_UK_CHAT_BYPASS_TOKEN || ''; + +interface BuildChatUrlOptions { + /** Optional report-derived context. Forwarded as a system-prompt seed. */ + scenarioContext?: string; +} + +export function buildChatUrl({ scenarioContext }: BuildChatUrlOptions = {}): string { + const params = new URLSearchParams(); + if (scenarioContext) { + params.set('scenario_context', scenarioContext); + } + // model_backend=uk_python so the chat runs against the same Python engine + // app-v2's reports use — needed for chat numbers to be comparable to the + // report numbers the user is looking at. + params.set('model_backend', 'uk_python'); + if (CHAT_BYPASS_TOKEN) { + params.set('x-vercel-protection-bypass', CHAT_BYPASS_TOKEN); + // samesitenone is required so the cookie Vercel sets is sent on + // cross-origin iframe requests in modern browsers (Lax/Strict won't be). + params.set('x-vercel-set-bypass-cookie', 'samesitenone'); + } + return `${CHAT_ORIGIN}/?${params.toString()}`; +} + +/** Permissions the iframe needs to match the chat's standalone behaviour. */ +export const CHAT_IFRAME_SANDBOX = + 'allow-scripts allow-same-origin allow-forms allow-popups allow-clipboard-read allow-clipboard-write'; diff --git a/website/src/data/appZoneRoutes.ts b/website/src/data/appZoneRoutes.ts index 1cb480b80..e830d29f5 100644 --- a/website/src/data/appZoneRoutes.ts +++ b/website/src/data/appZoneRoutes.ts @@ -60,8 +60,7 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/working-parents-tax-relief-act", - destination: - "https://wptra.vercel.app/us/working-parents-tax-relief-act", + destination: "https://wptra.vercel.app/us/working-parents-tax-relief-act", }, { source: "/us/utah-2026-tax-changes", @@ -74,7 +73,8 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/watca", - destination: "https://working-americans-tax-cut-act-one.vercel.app/us/watca", + destination: + "https://working-americans-tax-cut-act-one.vercel.app/us/watca", }, { source: "/us/california-wealth-tax", @@ -110,7 +110,8 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/uk/marriage", - destination: "https://marriage-zeta-beryl.vercel.app/us/marriage?country=uk", + destination: + "https://marriage-zeta-beryl.vercel.app/us/marriage?country=uk", deepDestination: "https://marriage-zeta-beryl.vercel.app/us/marriage/:path*?country=uk", }, @@ -180,8 +181,7 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/state-eitcs-ctcs", - destination: - "https://us-state-eitcs-ctcs.vercel.app/us/state-eitcs-ctcs", + destination: "https://us-state-eitcs-ctcs.vercel.app/us/state-eitcs-ctcs", }, { source: "/us/2024-election-calculator", @@ -213,7 +213,8 @@ export const appZoneRoutes: AppZoneRoute[] = [ }, { source: "/us/ads-dashboard", - destination: "https://policyengine-ads-dashboard.vercel.app/us/ads-dashboard", + destination: + "https://policyengine-ads-dashboard.vercel.app/us/ads-dashboard", }, { source: "/us/ai-inequality", @@ -226,6 +227,15 @@ export const appZoneRoutes: AppZoneRoute[] = [ deepDestination: "https://ai-inequality-theta.vercel.app/us/ai-inequality/:path*?country=uk", }, + { + // UK chat assistant — served as a multizone child so the chat lives at + // policyengine.org/uk/chat without an iframe. The chat app itself does + // not yet render the PolicyEngine site shell, so the shell audit skips + // this destination until the chat repo ships its own shell. + source: "/uk/chat", + destination: "https://policyengine-uk-chat.vercel.app/", + deepDestination: "https://policyengine-uk-chat.vercel.app/:path*", + }, ]; export const appZoneAssetRoutes: AppZoneRoute[] = [