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 ( + + ); +} 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 && ( +