Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 80 additions & 40 deletions .github/scripts/audit-app-zone-shell.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -419,20 +433,27 @@ 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;
if (status && status >= 400) {
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" };
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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:");
Expand All @@ -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;
}
Expand Down
96 changes: 96 additions & 0 deletions app/src/components/chat/AskChatCta.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={onClick}
type="button"
className="tw:group tw:w-full tw:text-left tw:cursor-pointer tw:transition-all tw:hover:shadow-md"
style={{
backgroundColor: colors.primary[50],
border: `1px solid ${colors.primary[200]}`,
borderRadius: spacing.radius.container,
padding: `${spacing.lg} ${spacing.xl}`,
marginBottom: spacing.lg,
display: 'flex',
alignItems: 'center',
gap: spacing.lg,
}}
>
<div
style={{
flexShrink: 0,
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: colors.primary[500],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconSparkles size={20} color={colors.white} />
</div>

<div className="tw:flex-1 tw:min-w-0">
<div
style={{
display: 'flex',
alignItems: 'center',
gap: spacing.sm,
marginBottom: '2px',
}}
>
<Text
style={{
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.title,
}}
>
Or just ask
</Text>
<span
style={{
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.medium,
color: colors.primary[700],
backgroundColor: colors.primary[100],
padding: '2px 8px',
borderRadius: '999px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
New
</span>
</div>
<Text size="sm" style={{ color: colors.text.secondary }}>
Skip the builder and ask a policy question in plain English. The assistant runs the
simulation for you.
</Text>
</div>

<IconArrowRight
size={20}
color={colors.primary[600]}
className="tw:transition-transform tw:group-hover:translate-x-1"
/>
</button>
);
}
41 changes: 41 additions & 0 deletions app/src/components/report/ChatDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="tw:!max-w-none tw:w-full tw:sm:w-[480px] tw:md:w-[560px] tw:lg:w-[640px] tw:p-0"
>
<SheetHeader className="tw:px-4 tw:py-3 tw:border-b">
<SheetTitle>Ask a follow-up</SheetTitle>
</SheetHeader>
{open && (
<iframe
src={buildChatUrl({ scenarioContext })}
title="PolicyEngine chat"
className="tw:flex-1 tw:w-full tw:border-0"
sandbox={CHAT_IFRAME_SANDBOX}
/>
)}
</SheetContent>
</Sheet>
);
}
19 changes: 18 additions & 1 deletion app/src/components/report/ReportActionButtons.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +10,7 @@ interface ReportActionButtonsProps {
onSave?: () => void;
onView?: () => void;
onReproduce?: () => void;
onAskFollowUp?: () => void;
}

/**
Expand All @@ -25,6 +26,7 @@ export function ReportActionButtons({
onSave,
onView,
onReproduce,
onAskFollowUp,
}: ReportActionButtonsProps) {
return (
<Group gap="xs" className="tw:ml-1.5">
Expand Down Expand Up @@ -68,6 +70,21 @@ export function ReportActionButtons({
<TooltipContent side="bottom">Reproduce in Python</TooltipContent>
</Tooltip>
)}
{onAskFollowUp && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label="Ask a follow-up about this report"
onClick={onAskFollowUp}
>
<IconMessageCircle size={18} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Ask a follow-up</TooltipContent>
</Tooltip>
)}
<ReportShareButton shareUrl={shareUrl} />
</Group>
);
Expand Down
Loading
Loading