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