Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions apps/app/src/app/api/pick/ip/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
44 changes: 44 additions & 0 deletions apps/app/src/app/api/pick/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
34 changes: 34 additions & 0 deletions apps/app/src/app/api/pick/store.ts
Original file line number Diff line number Diff line change
@@ -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<string, PickSession>();

// Auto-expire sessions after 10 minutes
const EXPIRY_MS = 10 * 60 * 1000;
const timers = new Map<string, ReturnType<typeof setTimeout>>();

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),
);
}
66 changes: 63 additions & 3 deletions apps/app/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,80 @@
"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() {
useGenerativeUIExamples();
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) => {
Expand Down Expand Up @@ -68,6 +120,7 @@ export default function HomePage() {
</p>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<QrButton onClick={openQrModal} />
<button
onClick={() => setDemoDrawerOpen(true)}
className="inline-flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm font-medium no-underline whitespace-nowrap transition-all duration-150 hover:-translate-y-px cursor-pointer"
Expand Down Expand Up @@ -118,6 +171,13 @@ export default function HomePage() {
/>

<DesktopTipModal />

<QrModal
isOpen={qrOpen}
onClose={() => setQrOpen(false)}
sessionId={qrSessionId}
scanStatus={scanStatus}
/>
</>
);
}
Loading
Loading