From 324c82ab1d4734caca2a9c79f7528c7f8efbe033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:31:33 -0400 Subject: [PATCH 1/6] feat: add QR code audience pick flow for live presentations Adds a QR button in the top banner that opens a modal with a scannable QR code. Audience members scan it, pick from 3 random visualization options, and the selected prompt is sent to the AI agent on the big screen. --- apps/app/package.json | 1 + apps/app/src/app/api/pick/ip/route.ts | 15 ++ apps/app/src/app/api/pick/route.ts | 37 +++ apps/app/src/app/api/pick/store.ts | 34 +++ apps/app/src/app/page.tsx | 41 ++++ apps/app/src/app/pick/page.tsx | 247 +++++++++++++++++++++ apps/app/src/components/qr-modal/index.tsx | 242 ++++++++++++++++++++ pnpm-lock.yaml | 12 + 8 files changed, 629 insertions(+) create mode 100644 apps/app/src/app/api/pick/ip/route.ts create mode 100644 apps/app/src/app/api/pick/route.ts create mode 100644 apps/app/src/app/api/pick/store.ts create mode 100644 apps/app/src/app/pick/page.tsx create mode 100644 apps/app/src/components/qr-modal/index.tsx diff --git a/apps/app/package.json b/apps/app/package.json index e6aa958..ba21391 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -16,6 +16,7 @@ "@copilotkit/runtime": "next", "@copilotkitnext/shared": "next", "next": "16.1.6", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-rnd": "^10.5.2", diff --git a/apps/app/src/app/api/pick/ip/route.ts b/apps/app/src/app/api/pick/ip/route.ts new file mode 100644 index 0000000..e355371 --- /dev/null +++ b/apps/app/src/app/api/pick/ip/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { networkInterfaces } from "os"; + +/** Returns the machine's LAN IPv4 address so phones on the same network can connect. */ +export async function GET() { + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (!net.internal && net.family === "IPv4") { + return NextResponse.json({ ip: net.address }); + } + } + } + return NextResponse.json({ ip: null }); +} diff --git a/apps/app/src/app/api/pick/route.ts b/apps/app/src/app/api/pick/route.ts new file mode 100644 index 0000000..fab56c4 --- /dev/null +++ b/apps/app/src/app/api/pick/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession, setSession } from "./store"; + +/** GET — poll session status (desktop polls this) */ +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("sessionId"); + if (!sessionId) return NextResponse.json({ error: "missing sessionId" }, { status: 400 }); + + const session = getSession(sessionId); + if (!session) return NextResponse.json({ status: "waiting" }); + + return NextResponse.json(session); +} + +/** PUT — mark session as scanned (mobile calls this on page load) */ +export async function PUT(req: NextRequest) { + const { sessionId } = await req.json(); + if (!sessionId) return NextResponse.json({ error: "missing sessionId" }, { status: 400 }); + + const existing = getSession(sessionId); + if (!existing || existing.status === "waiting") { + setSession(sessionId, { status: "scanned" }); + } + + return NextResponse.json({ ok: true }); +} + +/** POST — submit picked prompt (mobile calls this when user picks an option) */ +export async function POST(req: NextRequest) { + const { sessionId, prompt } = await req.json(); + if (!sessionId || !prompt) { + return NextResponse.json({ error: "missing sessionId or prompt" }, { status: 400 }); + } + + setSession(sessionId, { status: "picked", prompt }); + return NextResponse.json({ ok: true }); +} diff --git a/apps/app/src/app/api/pick/store.ts b/apps/app/src/app/api/pick/store.ts new file mode 100644 index 0000000..db16e66 --- /dev/null +++ b/apps/app/src/app/api/pick/store.ts @@ -0,0 +1,34 @@ +/** + * Simple in-memory session store for QR pick flow. + * Shared across API route handlers via module-level state. + */ + +export type PickSession = { + status: "waiting" | "scanned" | "picked"; + prompt?: string; +}; + +const sessions = new Map(); + +// Auto-expire sessions after 10 minutes +const EXPIRY_MS = 10 * 60 * 1000; +const timers = new Map>(); + +export function getSession(sessionId: string): PickSession | undefined { + return sessions.get(sessionId); +} + +export function setSession(sessionId: string, data: PickSession) { + sessions.set(sessionId, data); + + // Reset expiry timer + const existing = timers.get(sessionId); + if (existing) clearTimeout(existing); + timers.set( + sessionId, + setTimeout(() => { + sessions.delete(sessionId); + timers.delete(sessionId); + }, EXPIRY_MS), + ); +} diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 2f91244..6fb5b58 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -7,6 +7,7 @@ import { ExplainerCardsPortal } from "@/components/explainer-cards"; import { DemoGallery, type DemoItem } from "@/components/demo-gallery"; import { GridIcon } from "@/components/demo-gallery/grid-icon"; import { DesktopTipModal } from "@/components/desktop-tip-modal"; +import { QrButton, QrModal } from "@/components/qr-modal"; import { CopilotChat, useAgent, useCopilotKit } from "@copilotkit/react-core/v2"; export default function HomePage() { @@ -14,6 +15,9 @@ export default function HomePage() { useExampleSuggestions(); const [demoDrawerOpen, setDemoDrawerOpen] = useState(false); + const [qrOpen, setQrOpen] = useState(false); + const [qrSessionId] = useState(() => typeof crypto !== "undefined" ? crypto.randomUUID().slice(0, 12) : "fallback"); + const [scanStatus, setScanStatus] = useState<"waiting" | "scanned" | "picked">("waiting"); const { agent } = useAgent(); const { copilotkit } = useCopilotKit(); @@ -23,6 +27,35 @@ export default function HomePage() { copilotkit.runAgent({ agent }); }; + // Reset scan status when QR modal opens + useEffect(() => { + if (qrOpen) setScanStatus("waiting"); + }, [qrOpen]); + + // Poll for QR pick status + useEffect(() => { + if (!qrOpen) return; + const interval = setInterval(async () => { + try { + const res = await fetch(`/api/pick?sessionId=${qrSessionId}`); + const data = await res.json(); + if (data.status === "scanned") { + setScanStatus("scanned"); + } else if (data.status === "picked" && data.prompt) { + setScanStatus("picked"); + setTimeout(() => { + setQrOpen(false); + agent.addMessage({ id: crypto.randomUUID(), content: data.prompt, role: "user" }); + copilotkit.runAgent({ agent }); + }, 800); + } + } catch { + // ignore polling errors + } + }, 2000); + return () => clearInterval(interval); + }, [qrOpen, qrSessionId, agent, copilotkit]); + // Widget bridge: handle messages from widget iframes useEffect(() => { const handler = (e: MessageEvent) => { @@ -68,6 +101,7 @@ export default function HomePage() {

+ setQrOpen(true)} />
}> + + + ); +} + +function PickPage() { + const searchParams = useSearchParams(); + const sessionId = searchParams.get("session"); + + const [picked, setPicked] = useState(false); + const [error, setError] = useState(""); + + const options = useMemo(() => pickRandom(ALL_PROMPTS, 3), []); + + // Notify desktop that QR was scanned + useEffect(() => { + if (sessionId) { + fetch("/api/pick", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }).catch(() => {}); + } + }, [sessionId]); + + const handlePick = async (prompt: string) => { + if (!sessionId || picked) return; + setPicked(true); + try { + const res = await fetch("/api/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, prompt }), + }); + if (!res.ok) throw new Error("Failed to submit"); + } catch { + setError("Something went wrong. Try again."); + setPicked(false); + } + }; + + if (!sessionId) { + return ( +
+

Invalid link — scan the QR code from the main app.

+
+ ); + } + + if (picked) { + return ( +
+
+ +

Sent!

+

Look up at the big screen to see it come to life.

+
+
+ ); + } + + return ( +
+

Pick a Visualization

+

Tap one and the AI agent will build it live.

+ +
+ {options.map((opt) => ( + + ))} +
+ + {error &&

{error}

} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Inline styles — self-contained page, no dependency on app theme */ +/* ------------------------------------------------------------------ */ +const styles: Record = { + container: { + minHeight: "100dvh", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: "24px 16px", + fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", + background: "linear-gradient(145deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%)", + color: "#fff", + }, + heading: { + fontSize: 24, + fontWeight: 700, + margin: "0 0 4px", + textAlign: "center" as const, + }, + subheading: { + fontSize: 14, + color: "rgba(255,255,255,0.6)", + margin: "0 0 32px", + textAlign: "center" as const, + }, + grid: { + display: "flex", + flexDirection: "column" as const, + gap: 14, + width: "100%", + maxWidth: 340, + }, + card: { + display: "flex", + alignItems: "center", + gap: 14, + padding: "18px 20px", + borderRadius: 16, + border: "1px solid rgba(255,255,255,0.12)", + background: "rgba(255,255,255,0.06)", + backdropFilter: "blur(12px)", + color: "#fff", + fontSize: 16, + fontWeight: 600, + cursor: "pointer", + transition: "transform 0.15s, background 0.15s", + WebkitTapHighlightColor: "transparent", + textAlign: "left" as const, + fontFamily: "inherit", + }, + emoji: { + fontSize: 28, + lineHeight: 1, + }, + cardTitle: { + flex: 1, + }, + errorText: { + color: "#ff6b6b", + fontSize: 14, + marginTop: 16, + textAlign: "center" as const, + }, + successCard: { + display: "flex", + flexDirection: "column" as const, + alignItems: "center", + gap: 8, + color: "#85e0ce", + }, + successTitle: { + fontSize: 24, + fontWeight: 700, + margin: 0, + }, + successSub: { + fontSize: 14, + color: "rgba(255,255,255,0.6)", + margin: 0, + textAlign: "center" as const, + }, +}; diff --git a/apps/app/src/components/qr-modal/index.tsx b/apps/app/src/components/qr-modal/index.tsx new file mode 100644 index 0000000..c3c7f04 --- /dev/null +++ b/apps/app/src/components/qr-modal/index.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; + +/* ------------------------------------------------------------------ */ +/* Hook: resolves the pick URL (uses LAN IP on localhost for phones) */ +/* ------------------------------------------------------------------ */ +function usePickUrl(sessionId: string) { + const [url, setUrl] = useState(""); + + useEffect(() => { + const origin = window.location.origin; + if ( + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + ) { + fetch("/api/pick/ip") + .then((r) => r.json()) + .then((data) => { + if (data.ip) { + setUrl(`http://${data.ip}:${window.location.port}/pick?session=${sessionId}`); + } else { + setUrl(`${origin}/pick?session=${sessionId}`); + } + }) + .catch(() => setUrl(`${origin}/pick?session=${sessionId}`)); + } else { + setUrl(`${origin}/pick?session=${sessionId}`); + } + }, [sessionId]); + + return url; +} + +/* ------------------------------------------------------------------ */ +/* QR Modal */ +/* ------------------------------------------------------------------ */ +export function QrModal({ + isOpen, + onClose, + sessionId, + scanStatus, +}: { + isOpen: boolean; + onClose: () => void; + sessionId: string; + scanStatus: "waiting" | "scanned" | "picked"; +}) { + const pickUrl = usePickUrl(sessionId); + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal */} +
+
+ {/* Close button */} + + + {/* Header */} +
+

+ Scan to Pick a Visualization +

+

+ Scan with your phone to choose what the AI agent builds next +

+
+ + {/* QR Code */} +
+ {pickUrl ? ( + + ) : ( +
+ Loading... +
+ )} +
+ + {/* Status */} +
+ + {scanStatus === "waiting" && "Waiting for scan..."} + {scanStatus === "scanned" && "Scanned! Waiting for pick..."} + {scanStatus === "picked" && "Pick received!"} +
+
+
+ + + + ); +} + +/* ------------------------------------------------------------------ */ +/* QR Button (for the top banner) */ +/* ------------------------------------------------------------------ */ +export function QrButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31cad4b..20c128f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -4189,6 +4192,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -10017,6 +10025,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + qs@6.14.1: dependencies: side-channel: 1.1.0 From cf6780b8aa5983dbf2907e6d1fa8d881d20494c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:44:28 -0400 Subject: [PATCH 2/6] fix: use refs for agent dispatch in QR pick polling Prevents stale closure issues when the setTimeout fires after modal close. Also stops polling immediately after first pick to avoid duplicate agent runs. --- apps/app/src/app/page.tsx | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 6fb5b58..327801a 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ExampleLayout } from "@/components/example-layout"; import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks"; import { ExplainerCardsPortal } from "@/components/explainer-cards"; @@ -21,10 +21,22 @@ export default function HomePage() { const { agent } = useAgent(); const { copilotkit } = useCopilotKit(); + // Ref to always have the latest agent/copilotkit for async callbacks + const agentRef = useRef(agent); + const copilotkitRef = useRef(copilotkit); + agentRef.current = agent; + copilotkitRef.current = copilotkit; + + const sendPrompt = useCallback((prompt: string) => { + const a = agentRef.current; + const ck = copilotkitRef.current; + a.addMessage({ id: crypto.randomUUID(), content: prompt, role: "user" }); + ck.runAgent({ agent: a }); + }, []); + const handleTryDemo = (demo: DemoItem) => { setDemoDrawerOpen(false); - agent.addMessage({ id: crypto.randomUUID(), content: demo.prompt, role: "user" }); - copilotkit.runAgent({ agent }); + sendPrompt(demo.prompt); }; // Reset scan status when QR modal opens @@ -35,18 +47,21 @@ export default function HomePage() { // Poll for QR pick status useEffect(() => { if (!qrOpen) return; + let picked = false; const interval = setInterval(async () => { + if (picked) return; try { const res = await fetch(`/api/pick?sessionId=${qrSessionId}`); const data = await res.json(); if (data.status === "scanned") { setScanStatus("scanned"); } else if (data.status === "picked" && data.prompt) { + picked = true; setScanStatus("picked"); + clearInterval(interval); setTimeout(() => { setQrOpen(false); - agent.addMessage({ id: crypto.randomUUID(), content: data.prompt, role: "user" }); - copilotkit.runAgent({ agent }); + sendPrompt(data.prompt); }, 800); } } catch { @@ -54,7 +69,7 @@ export default function HomePage() { } }, 2000); return () => clearInterval(interval); - }, [qrOpen, qrSessionId, agent, copilotkit]); + }, [qrOpen, qrSessionId, sendPrompt]); // Widget bridge: handle messages from widget iframes useEffect(() => { From 3e7bc4e1fedbde2e3cfdb13109db0a5b0cdaceab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:52:09 -0400 Subject: [PATCH 3/6] fix: resolve lint errors in QR pick flow - Move ref updates into effects to satisfy react-hooks/refs rule - Remove synchronous setState in effects (set-state-in-effect rule) - Compute non-localhost URL eagerly in usePickUrl initializer --- apps/app/src/app/page.tsx | 14 ++++---- apps/app/src/components/qr-modal/index.tsx | 42 +++++++++++----------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 327801a..bdf7f2d 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -24,8 +24,8 @@ export default function HomePage() { // Ref to always have the latest agent/copilotkit for async callbacks const agentRef = useRef(agent); const copilotkitRef = useRef(copilotkit); - agentRef.current = agent; - copilotkitRef.current = copilotkit; + useEffect(() => { agentRef.current = agent; }, [agent]); + useEffect(() => { copilotkitRef.current = copilotkit; }, [copilotkit]); const sendPrompt = useCallback((prompt: string) => { const a = agentRef.current; @@ -39,10 +39,10 @@ export default function HomePage() { sendPrompt(demo.prompt); }; - // Reset scan status when QR modal opens - useEffect(() => { - if (qrOpen) setScanStatus("waiting"); - }, [qrOpen]); + const openQrModal = () => { + setScanStatus("waiting"); + setQrOpen(true); + }; // Poll for QR pick status useEffect(() => { @@ -116,7 +116,7 @@ export default function HomePage() {

- setQrOpen(true)} /> +