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..8b9e543
--- /dev/null
+++ b/apps/app/src/app/api/pick/route.ts
@@ -0,0 +1,44 @@
+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" });
+
+ // Consume the prompt on first read so duplicate polls don't re-trigger
+ if (session.status === "picked" && session.prompt) {
+ const prompt = session.prompt;
+ setSession(sessionId, { status: "picked" });
+ return NextResponse.json({ status: "picked", prompt });
+ }
+
+ 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..87d3235 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -1,12 +1,13 @@
"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";
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,15 +15,66 @@ export default function HomePage() {
useExampleSuggestions();
const [demoDrawerOpen, setDemoDrawerOpen] = useState(false);
+ const [qrOpen, setQrOpen] = useState(false);
+ const [qrSessionId, setQrSessionId] = useState("");
+ const [scanStatus, setScanStatus] = useState<"waiting" | "scanned" | "picked">("waiting");
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);
+ useEffect(() => { agentRef.current = agent; }, [agent]);
+ useEffect(() => { copilotkitRef.current = copilotkit; }, [copilotkit]);
+
+ // Guard: prevent duplicate prompt dispatch across re-renders
+ const pickedRef = useRef(false);
+
+ 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);
+ };
+
+ const openQrModal = () => {
+ pickedRef.current = false;
+ setScanStatus("waiting");
+ setQrSessionId(crypto.randomUUID().slice(0, 12));
+ setQrOpen(true);
};
+ // Poll for QR pick status
+ useEffect(() => {
+ if (!qrOpen || !qrSessionId) return;
+ const interval = setInterval(async () => {
+ if (pickedRef.current) 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 && !pickedRef.current) {
+ pickedRef.current = true;
+ clearInterval(interval);
+ setScanStatus("picked");
+ setTimeout(() => {
+ setQrOpen(false);
+ sendPrompt(data.prompt);
+ }, 800);
+ }
+ } catch {
+ // ignore polling errors
+ }
+ }, 2000);
+ return () => clearInterval(interval);
+ }, [qrOpen, qrSessionId, sendPrompt]);
+
// Widget bridge: handle messages from widget iframes
useEffect(() => {
const handler = (e: MessageEvent) => {
@@ -68,6 +120,7 @@ export default function HomePage() {
+
}>
+
+
+ );
+}
+
+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..756f037
--- /dev/null
+++ b/apps/app/src/components/qr-modal/index.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useEffect, useMemo, 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 isLocalhost =
+ typeof window !== "undefined" &&
+ (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
+
+ // LAN IP resolved once (only used on localhost)
+ const [lanIp, setLanIp] = useState(null);
+
+ useEffect(() => {
+ if (!isLocalhost) return;
+ fetch("/api/pick/ip")
+ .then((r) => r.json())
+ .then((data) => { if (data.ip) setLanIp(data.ip); })
+ .catch(() => {});
+ }, [isLocalhost]);
+
+ return useMemo(() => {
+ if (!sessionId || typeof window === "undefined") return "";
+ if (isLocalhost && lanIp) {
+ return `http://${lanIp}:${window.location.port}/pick?session=${sessionId}`;
+ }
+ return `${window.location.origin}/pick?session=${sessionId}`;
+ }, [sessionId, isLocalhost, lanIp]);
+}
+
+/* ------------------------------------------------------------------ */
+/* 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