├── README.md ├── agent ├── .env.example ├── .gitignore ├── README.md ├── langgraph.json ├── main.py ├── pyproject.toml └── uv.lock └── web ├── .gitignore ├── README.md ├── app ├── (dashboard) │ ├── [chatId] │ │ └── page.tsx │ ├── layout.tsx │ └── n │ │ └── page.tsx ├── error.tsx ├── favicon.ico ├── global-error.tsx ├── globals.css ├── guide │ ├── layout.tsx │ └── page.tsx ├── handler │ └── [...stack] │ │ └── page.tsx ├── layout.tsx ├── loading.tsx ├── not-found.tsx └── page.tsx ├── components.json ├── components ├── canvas-component.tsx ├── chat-sidebar.tsx ├── chat.tsx ├── interrupt-handler.tsx ├── interrupts │ ├── activity-selector.tsx │ ├── date-selector.tsx │ ├── destination-selector.tsx │ └── generic-handler.tsx ├── markdown-renderer.tsx ├── markdown-text.tsx ├── thread.tsx ├── tooltip-icon-button.tsx ├── trip-card.tsx ├── trip-dialog.tsx └── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── canvas.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── date-range-picker.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── textarea.tsx │ ├── tooltip-icon-button.tsx │ └── tooltip.tsx ├── context ├── action.tsx ├── canvas-context.tsx └── utils.ts ├── env.example ├── eslint.config.mjs ├── hooks └── use-mobile.tsx ├── lib ├── convert_messages.ts ├── langgraph-client.ts └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── music-record.png ├── next.svg ├── vaporwave.png ├── vercel.svg └── window.svg ├── stack.tsx ├── tailwind.config.ts ├── tsconfig.json └── types └── index.tsx /README.md: -------------------------------------------------------------------------------- 1 | # Canvas Callback 2 | 3 | [![Canvas Callback Demo](https://img.youtube.com/vi/589W-9Ojahw/maxresdefault.jpg)](https://www.youtube.com/watch?v=589W-9Ojahw) 4 | _👆 Click to watch the Canvas Callback demo on YouTube_ 5 | 6 | Canvas Callback is an open-source implementation that demonstrates how to transform AI chat interfaces into interactive visual workspaces using LangGraph's interrupt for human-in-the-loop workflows. 7 | 8 | **[Try the Demo](https://canvascallback.vercel.app/n)** | **[Read the Guide](https://canvascallback.vercel.app/guide)** 9 | 10 | ## What is Canvas Callback? 11 | 12 | Canvas Callback showcases a powerful pattern for building collaborative AI applications that go beyond typical chat interfaces: 13 | 14 | - **Canvas UX Pattern**: A dedicated workspace alongside chat for rich, interactive content 15 | - **Human-in-the-Loop**: LangGraph interrupts for collecting structured user input 16 | - **Joint Problem Solving**: Create experiences where humans and AI can truly collaborate 17 | 18 | The project demonstrates how these patterns work together in a travel planning agent that interrupts its workflow to collect specific information through specialized UIs. 19 | 20 | ## Core Features 21 | 22 | - 🖼️ **Canvas UI Component**: A flexible, compound component for creating persistent visual workspaces 23 | - 🔄 **Interrupt Handling**: Type-based routing system for different interrupt types 24 | - 🧩 **Modular Architecture**: Clean separation between UI, state management, and agent logic 25 | - 🌐 **LangGraph Integration**: Full implementation using LangGraph's interrupt 26 | - 🧠 **Interactive Demo**: Practical travel planning agent showcasing the patterns in action 27 | 28 | ## Architecture Overview 29 | 30 | ``` 31 | ┌────────────────────────────────────────┐ 32 | │ UI Layer │ 33 | │ ┌──────────┐ ┌─────────────┐ │ 34 | │ │ Chat │◄────────►│ Canvas │ │ 35 | │ └──────────┘ └─────────────┘ │ 36 | └────────────┬───────────────┬───────────┘ 37 | │ │ 38 | ▼ ▼ 39 | ┌────────────────┐ ┌──────────────────┐ 40 | │ Message Handler│ │ Interrupt Handler│ 41 | └────────┬───────┘ └────────┬─────────┘ 42 | │ │ 43 | ▼ ▼ 44 | ┌────────────────────────────────────────┐ 45 | │ LangGraph Runtime │ 46 | │ │ 47 | │ ┌───────────┐ ┌───────────────┐ │ 48 | │ │ Agent │◄────►│ Interrupts │ │ 49 | │ └───────────┘ └───────────────┘ │ 50 | └────────────────────────────────────────┘ 51 | ``` 52 | 53 | ### Key Components 54 | 55 | - **Thread.tsx**: Manages the chat interface and message display 56 | - **Canvas.tsx**: Implements the canvas UI component 57 | - **InterruptHandler.tsx**: Routes different interrupt types to specialized UIs 58 | - **Specific Interrupt Components**: Specialized UIs for different interrupt types 59 | - **LangGraph SDK**: Communicates with the LangGraph server 60 | 61 | ## Project Scope 62 | 63 | Canvas Callback is designed as a focused, accessible implementation to help you understand and integrate the Canvas UX pattern using LangGraph interrupts. Once you've understand these core concepts, you may want to explore [LangChain's OpenCanvas](https://github.com/langchain-ai/open-canvas) for advanced features like memory systems, custom actions, and artifact versioning. 64 | 65 | ## Use Cases 66 | 67 | Canvas Callback's pattern can be applied to diverse domains: 68 | 69 | - **Travel Planning**: Interactive destination selection and itinerary building 70 | - **Education**: Shared workspaces for tutoring and problem-solving 71 | - **Data Analysis**: Collaborative data exploration and visualization 72 | - **Healthcare**: Visual symptom assessment and tracking 73 | - **Product Design**: Collaborative design sessions with structured feedback 74 | 75 | ## Getting Started 76 | 77 | ### Prerequisites 78 | 79 | - Node.js 18+ 80 | - Python 3.11+ 81 | - LangGraph CLI 82 | 83 | ### Installation 84 | 85 | 1. Clone the repository: 86 | 87 | ```bash 88 | git clone https://github.com/ahmad2b/canvas-callback.git 89 | cd canvas-callback 90 | ``` 91 | 92 | 2. Install frontend dependencies: 93 | 94 | ```bash 95 | cd web 96 | npm install 97 | # or 98 | pnpm install 99 | ``` 100 | 101 | 3. Install LangGraph CLI and backend dependencies: 102 | 103 | ```bash 104 | cd ../agent 105 | 106 | # Install LangGraph CLI 107 | pip install -U "langgraph-cli[inmem]" 108 | 109 | # Install project dependencies 110 | poetry install 111 | # or 112 | pip install -r requirements.txt 113 | ``` 114 | 115 | 4. Set up environment variables: 116 | 117 | ```bash 118 | cp web/.env.example web/.env 119 | cp agent/.env.example agent/.env 120 | ``` 121 | 122 | Add your API keys and configuration details to both .env files. 123 | 124 | For LangGraph, you'll need a LangSmith API key which can be created from the LangSmith UI (Settings > API Keys). Add it to your agent/.env file: 125 | 126 | ``` 127 | LANGSMITH_API_KEY=your_api_key_here 128 | ``` 129 | 130 | 5. Start the development server: 131 | 132 | ```bash 133 | # Terminal 1 - Frontend 134 | cd web 135 | npm run dev 136 | 137 | # Terminal 2 - LangGraph Agent 138 | cd agent 139 | langgraph dev 140 | ``` 141 | 142 | When the LangGraph server starts successfully, you'll see something like: 143 | 144 | ``` 145 | Ready! 146 | * API: http://localhost:2024 147 | * Docs: http://localhost:2024/docs 148 | * LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 149 | ``` 150 | 151 | 6. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. 152 | 153 | ## Resources 154 | 155 | ### LangGraph Documentation 156 | 157 | - [LangGraph Interrupt API Guide](https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/) 158 | - [Human-in-the-Loop Patterns](https://blog.langchain.dev/making-it-easier-to-build-human-in-the-loop-agents-with-interrupt/) 159 | - [LangGraph Server Documentation](https://langchain-ai.github.io/langgraph/concepts/langgraph_server/) 160 | - [Testing LangGraph Apps Locally](https://langchain-ai.github.io/langgraph/cloud/deployment/test_locally/) 161 | 162 | ### UI Resources 163 | 164 | - [Assistant UI for AI Chat Interfaces](https://github.com/assistant-ui/assistant-ui) 165 | - [LangChain's OpenCanvas Implementation](https://github.com/langchain-ai/open-canvas) 166 | 167 | ## Contributing 168 | 169 | Contributions are welcome! Please feel free to submit a Pull Request. 170 | 171 | ## License 172 | 173 | This project is licensed under the MIT License 174 | 175 | ## Connect 176 | 177 | **Join the Canvas Conversation** 178 | Exploring Canvas patterns or building human-in-the-loop AI interfaces? I'd love to hear about your projects and exchange ideas with the community. 179 | 180 | **Share Your Implementation** 181 | If you build something with these patterns, consider sharing it! Your innovations can help evolve this approach. 182 | 183 | - [GitHub](https://github.com/ahmad2b) 184 | - [X](https://x.com/mahmad2b) 185 | - [LinkedIn](https://www.linkedin.com/in/ahmad2b) 186 | - ahmadshaukat_4@outlook.com 187 | - [Connect for a chat](https://cal.com/mahmad2b/15min) 188 | -------------------------------------------------------------------------------- /agent/.env.example: -------------------------------------------------------------------------------- 1 | LANGSMITH_TRACING=true 2 | LANGSMITH_API_KEY=your_langsmith_api_key 3 | 4 | OPENAI_API_KEY=your_openai_api_key -------------------------------------------------------------------------------- /agent/.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .langgraph_api 12 | .langgraph_api/* 13 | .env 14 | .python-version -------------------------------------------------------------------------------- /agent/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahmad2b/canvas-callback/08ebc61f7a893520ee333768156ee918cd90b876/agent/README.md -------------------------------------------------------------------------------- /agent/langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "graphs": { 3 | "agent": "./main.py:graph" 4 | }, 5 | "env": ".env", 6 | "dependencies": [ 7 | "." 8 | ], 9 | "dockerfile_lines": [] 10 | } -------------------------------------------------------------------------------- /agent/main.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | from langgraph.graph import StateGraph, START, END 4 | from langgraph.types import interrupt 5 | from langchain_openai import ChatOpenAI 6 | from langgraph_supervisor import create_supervisor 7 | 8 | from langgraph.prebuilt.chat_agent_executor import AgentState 9 | 10 | 11 | load_dotenv() 12 | 13 | class TravelState(AgentState): 14 | destination:str 15 | dates:str 16 | activities:list[str] 17 | trips:str 18 | 19 | def ask_destination(state: TravelState): 20 | 21 | state["destination"] = interrupt( 22 | { 23 | "type":"destination", 24 | "data":"Where do you want to go?" 25 | } 26 | ) 27 | return state 28 | 29 | def ask_dates(state: TravelState): 30 | 31 | state["dates"] = interrupt( 32 | { 33 | "type":"dates", 34 | "data":"When do you want to go?" 35 | } 36 | ) 37 | return state 38 | 39 | def ask_activities(state: TravelState): 40 | 41 | state["activities"] = interrupt( 42 | { 43 | "type":"activities", 44 | "data":"What activities are you interested in?" 45 | } 46 | ) 47 | return state 48 | 49 | def find_trips(state: TravelState): 50 | 51 | dates_data = state['dates'] 52 | dates_obj = None 53 | 54 | # Handle different possible date formats 55 | if isinstance(dates_data, dict) and 'dates' in dates_data: 56 | # Handle nested dates object 57 | dates_obj = dates_data['dates'] 58 | elif isinstance(dates_data, str): 59 | # Try to parse JSON-like string if it contains startDate/endDate 60 | if "startDate" in dates_data and "endDate" in dates_data: 61 | try: 62 | # Clean up the string and convert to proper dictionary 63 | cleaned_str = dates_data.replace("'", "\"") 64 | import json 65 | dates_obj = json.loads(cleaned_str) 66 | except: 67 | # Fallback to simple object if parsing fails 68 | dates_obj = {"startDate": dates_data, "endDate": dates_data} 69 | else: 70 | # Use date string as both start and end if no structured data 71 | dates_obj = {"startDate": dates_data, "endDate": dates_data} 72 | else: 73 | # Default fallback 74 | dates_obj = {"startDate": str(dates_data), "endDate": str(dates_data)} 75 | 76 | # Structure the trips data to match frontend expectations 77 | state["trips"] = { 78 | "destination": state['destination'], 79 | "dates": dates_obj, 80 | "activities": state['activities'] 81 | } 82 | 83 | # Create a human-readable trip description for the message 84 | trip_description = ( 85 | f"Here are some trips to {state['destination']} around {dates_obj.get('startDate', '')} " 86 | f"to {dates_obj.get('endDate', '')} with activities like {', '.join(state['activities'])}. " 87 | ) 88 | 89 | 90 | return { 91 | "messages": [ 92 | { 93 | "role": "assistant", 94 | "content": [ 95 | { 96 | "type": "text", 97 | "text": trip_description 98 | }, 99 | { 100 | "type": "text", 101 | "text": "Here's a trip card you can save and share with your friends and family!" 102 | } 103 | ] 104 | }, 105 | ], 106 | "trips": state["trips"] 107 | } 108 | 109 | model = ChatOpenAI(model="gpt-4o-mini") 110 | 111 | builder = StateGraph(TravelState) 112 | 113 | builder.add_node("ask_destination", ask_destination) 114 | builder.add_node("ask_dates", ask_dates) 115 | builder.add_node("ask_activities", ask_activities) 116 | builder.add_node("find_trips", find_trips) 117 | 118 | builder.add_edge(START, "ask_destination") 119 | builder.add_edge("ask_destination", "ask_dates") 120 | builder.add_edge("ask_dates", "ask_activities") 121 | builder.add_edge("ask_activities", "find_trips") 122 | builder.add_edge("find_trips", END) 123 | 124 | travel_agent = builder.compile(name="travel_agent") 125 | 126 | workflow = create_supervisor( 127 | [travel_agent], 128 | model=model, 129 | prompt=( 130 | "You are a supervisor AI that coordinates with specialized agents. " 131 | "IMPORTANT: For ANY travel-related questions or requests, ALWAYS delegate to the travel agent without exception. " 132 | "This includes anything about destinations, trips, vacations, flights, hotels, activities, or tourism. " 133 | "The travel agent is designed to collect information in a specific sequence about destination, dates, and activities. " 134 | "Only respond directly to questions that are completely unrelated to travel. " 135 | "If there's even a slight connection to travel, delegate to the travel agent." 136 | "After recieving the data from the travel agent, share the trip data with the user." 137 | ), 138 | state_schema=TravelState 139 | 140 | ) 141 | 142 | graph = workflow.compile(name="supervisor_agent") 143 | -------------------------------------------------------------------------------- /agent/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "agent" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "dotenv>=0.9.9", 9 | "langchain-anthropic>=0.3.8", 10 | "langchain-openai>=0.3.7", 11 | "langgraph-supervisor>=0.0.4", 12 | "langgraph>=0.2.75", 13 | "pillow>=11.1.0", 14 | ] 15 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | .vercel 44 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahmad2b/canvas-callback/08ebc61f7a893520ee333768156ee918cd90b876/web/README.md -------------------------------------------------------------------------------- /web/app/(dashboard)/[chatId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { BaseMessage } from "@langchain/core/messages"; 2 | 3 | import { CanvasComponent } from "@/components/canvas-component"; 4 | import { createClient } from "@/lib/langgraph-client"; 5 | import { extractInterruptData } from "@/lib/utils"; 6 | 7 | interface ChatPageProps { 8 | params: Promise<{ 9 | chatId: string; 10 | }>; 11 | } 12 | 13 | export default async function ChatPage({ params }: ChatPageProps) { 14 | const { chatId } = await params; 15 | 16 | const client = createClient(); 17 | const thread = await client.threads.get(chatId); 18 | 19 | const interruptData = extractInterruptData(thread); 20 | 21 | const agentState = thread.values as Record; 22 | const messages = Array.isArray(agentState?.messages) 23 | ? (agentState.messages as BaseMessage[]) 24 | : []; 25 | 26 | return ( 27 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /web/app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ChatSidebar } from "@/components/chat-sidebar"; 2 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 3 | import { CanvasProvider } from "@/context/canvas-context"; 4 | 5 | export default function DashboardLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /web/app/(dashboard)/n/page.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/langgraph-client"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default async function ChatPage() { 5 | const client = createClient(); 6 | const thread = await client.threads.create({ 7 | metadata: { 8 | userId: "test_user", 9 | }, 10 | }); 11 | 12 | redirect(`/${thread.thread_id}`); 13 | } 14 | -------------------------------------------------------------------------------- /web/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error boundaries must be Client Components 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Home, RefreshCw } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { useEffect } from "react"; 7 | 8 | export default function Error({ 9 | error, 10 | reset, 11 | }: { 12 | error: Error & { digest?: string }; 13 | reset: () => void; 14 | }) { 15 | useEffect(() => { 16 | // Log the error to an error reporting service 17 | console.error("Application error:", error); 18 | }, [error]); 19 | 20 | return ( 21 |
22 | {/* Background dot pattern */} 23 |
24 |
25 |
26 |
27 |
28 | 29 |
30 |
31 |
32 | ! 33 |
34 |
35 | 36 |

37 | ERROR ENCOUNTERED 38 |

39 | 40 |

41 | Something went wrong while loading this page. 42 |

43 | 44 | {error.digest && ( 45 |
46 |

Error ID: {error.digest}

47 |
48 | )} 49 | 50 |
51 | 58 | 59 | 69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahmad2b/canvas-callback/08ebc61f7a893520ee333768156ee918cd90b876/web/app/favicon.ico -------------------------------------------------------------------------------- /web/app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { RefreshCw } from "lucide-react"; 5 | import { useEffect } from "react"; 6 | 7 | export default function GlobalError({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error & { digest?: string }; 12 | reset: () => void; 13 | }) { 14 | useEffect(() => { 15 | // Log the error to an error reporting service 16 | console.error("Global error:", error); 17 | }, [error]); 18 | 19 | return ( 20 | 21 | 22 |
23 | {/* Simplified background */} 24 |
25 | 26 |
27 |
28 |
29 | X 30 |
31 |
32 | 33 |

34 | CRITICAL ERROR 35 |

36 | 37 |

38 | A critical application error has occurred. This is likely a 39 | problem with the application itself. 40 |

41 | 42 | {error.digest && ( 43 |
44 |

Error ID: {error.digest}

45 |
46 | )} 47 | 48 |
49 | 56 |
57 |
58 |
59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /web/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 35 30% 97%; 8 | --foreground: 35 10% 15%; 9 | 10 | --card: 40 35% 98%; 11 | --card-foreground: 35 10% 15%; 12 | 13 | --popover: 40 35% 98%; 14 | --popover-foreground: 35 10% 15%; 15 | 16 | --primary: 32 95% 50%; /* Retro orange with modern saturation */ 17 | --primary-foreground: 35 100% 98%; 18 | 19 | --secondary: 190 85% 65%; /* Modern teal */ 20 | --secondary-foreground: 190 30% 15%; 21 | 22 | --muted: 35 15% 94%; 23 | --muted-foreground: 35 10% 40%; 24 | 25 | --accent: 330 75% 65%; /* Modern purple */ 26 | --accent-foreground: 330 10% 97%; 27 | 28 | --destructive: 0 85% 60%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 35 30% 88%; 32 | --input: 35 30% 88%; 33 | --ring: 32 95% 50%; 34 | 35 | --chart-1: 32 95% 50%; 36 | --chart-2: 190 85% 65%; 37 | --chart-3: 330 75% 65%; 38 | --chart-4: 160 85% 45%; 39 | --chart-5: 275 75% 55%; 40 | 41 | --radius: 0.5rem; 42 | 43 | --sidebar-background: 35 40% 95%; 44 | --sidebar-foreground: 35 10% 20%; 45 | --sidebar-primary: 32 95% 50%; 46 | --sidebar-primary-foreground: 35 100% 98%; 47 | --sidebar-accent: 35 35% 90%; 48 | --sidebar-accent-foreground: 35 10% 20%; 49 | --sidebar-border: 35 30% 88%; 50 | --sidebar-ring: 32 95% 50%; 51 | } 52 | 53 | .dark { 54 | --background: 215 20% 15%; 55 | --foreground: 210 5% 90%; 56 | 57 | --card: 215 20% 18%; 58 | --card-foreground: 210 5% 90%; 59 | 60 | --popover: 215 20% 18%; 61 | --popover-foreground: 210 5% 90%; 62 | 63 | --primary: 32 85% 60%; /* Retro orange - modern brightness in dark mode */ 64 | --primary-foreground: 0 0% 100%; 65 | 66 | --secondary: 190 75% 55%; /* Modern teal */ 67 | --secondary-foreground: 210 5% 97%; 68 | 69 | --muted: 215 25% 25%; 70 | --muted-foreground: 210 5% 70%; 71 | 72 | --accent: 330 65% 55%; /* Modern purple */ 73 | --accent-foreground: 210 5% 97%; 74 | 75 | --destructive: 0 75% 50%; 76 | --destructive-foreground: 0 0% 98%; 77 | 78 | --border: 215 20% 30%; 79 | --input: 215 20% 30%; 80 | --ring: 32 85% 60%; 81 | 82 | --chart-1: 32 100% 60%; 83 | --chart-2: 190 85% 65%; 84 | --chart-3: 330 75% 65%; 85 | --chart-4: 160 85% 65%; 86 | --chart-5: 275 75% 70%; 87 | 88 | --sidebar-background: 215 25% 20%; 89 | --sidebar-foreground: 210 5% 90%; 90 | --sidebar-primary: 32 85% 60%; 91 | --sidebar-primary-foreground: 0 0% 100%; 92 | --sidebar-accent: 215 20% 25%; 93 | --sidebar-accent-foreground: 210 5% 90%; 94 | --sidebar-border: 215 20% 30%; 95 | --sidebar-ring: 32 85% 60%; 96 | } 97 | } 98 | 99 | @layer base { 100 | * { 101 | @apply border-border outline-ring/50; 102 | } 103 | body { 104 | @apply bg-background text-foreground; 105 | background-image: linear-gradient( 106 | to right, 107 | rgba(128, 128, 128, 0.03) 1px, 108 | transparent 1px 109 | ), 110 | linear-gradient(to bottom, rgba(128, 128, 128, 0.03) 1px, transparent 1px); 111 | background-size: 20px 20px; 112 | } 113 | h1, 114 | h2, 115 | h3, 116 | h4, 117 | .retro-heading { 118 | font-family: "Chakra Petch", sans-serif; 119 | font-weight: 700; 120 | } 121 | .pixel-font { 122 | font-family: "VT323", monospace; 123 | } 124 | .arcade-font { 125 | font-family: "Press Start 2P", monospace; 126 | line-height: 1.5; 127 | } 128 | } 129 | 130 | /* Retro-Modern Fusion Elements */ 131 | .retro-box { 132 | @apply border border-primary/40 rounded-lg shadow-[3px_3px_0px_0px_rgba(0,0,0,0.1)] backdrop-blur-[2px]; 133 | transition: all 0.2s ease-in-out; 134 | } 135 | .retro-box:hover { 136 | @apply shadow-[4px_4px_0px_0px_rgba(0,0,0,0.15)]; 137 | } 138 | 139 | .retro-button { 140 | @apply bg-primary text-primary-foreground font-bold py-2 px-4 border-b-2 border-r-2 border-primary-foreground/20 hover:border-primary-foreground/10 active:border-b-0 active:border-r-0 active:translate-x-1 active:translate-y-1 transition-all rounded-md shadow-none; 141 | transition: all 0.15s ease-in-out; 142 | } 143 | .retro-button:hover { 144 | @apply transform -translate-y-0.5; 145 | } 146 | 147 | .retro-card { 148 | @apply bg-card border border-border rounded-lg p-4 shadow-[3px_3px_0px_0px_rgba(0,0,0,0.08)]; 149 | transition: all 0.2s ease-in-out; 150 | } 151 | .retro-card:hover { 152 | @apply shadow-[5px_5px_0px_0px_rgba(0,0,0,0.1)] transform -translate-y-0.5; 153 | } 154 | 155 | .retro-input { 156 | @apply bg-card border border-border rounded-md p-2 focus:border-primary focus:ring-1 focus:ring-primary transition-colors duration-200; 157 | } 158 | 159 | .scanlines { 160 | position: relative; 161 | } 162 | 163 | .scanlines:before { 164 | content: ""; 165 | pointer-events: none; 166 | position: absolute; 167 | top: 0; 168 | left: 0; 169 | width: 100%; 170 | height: 100%; 171 | background: repeating-linear-gradient( 172 | 0deg, 173 | rgba(0, 0, 0, 0.03), 174 | rgba(0, 0, 0, 0.03) 1px, 175 | transparent 1px, 176 | transparent 2px 177 | ); 178 | z-index: 10; 179 | opacity: 0.7; 180 | } 181 | 182 | .crt-screen { 183 | position: relative; 184 | overflow: hidden; 185 | border-radius: 12px; 186 | } 187 | 188 | .crt-screen:before { 189 | content: ""; 190 | pointer-events: none; 191 | position: absolute; 192 | top: 0; 193 | left: 0; 194 | width: 100%; 195 | height: 100%; 196 | background: radial-gradient( 197 | ellipse at center, 198 | transparent 0%, 199 | rgba(0, 0, 0, 0.08) 90%, 200 | rgba(0, 0, 0, 0.15) 100% 201 | ); 202 | z-index: 10; 203 | opacity: 0.8; 204 | } 205 | 206 | /* Modern glass effect */ 207 | .glass-panel { 208 | @apply bg-white/40 backdrop-blur-md border border-white/20 rounded-xl; 209 | } 210 | 211 | /* Modern glow effects */ 212 | .glow-primary { 213 | box-shadow: 0 0 15px rgba(var(--primary), 0.3); 214 | } 215 | 216 | .glow-accent { 217 | box-shadow: 0 0 15px rgba(var(--accent), 0.3); 218 | } 219 | 220 | /* Pixel elements with modern smoothing */ 221 | .pixel-box { 222 | image-rendering: pixelated; 223 | @apply transition-transform duration-300; 224 | } 225 | 226 | .scrollbar-hide::-webkit-scrollbar { 227 | display: none; 228 | } 229 | 230 | /* Hide scrollbar for IE, Edge and Firefox */ 231 | .scrollbar-hide { 232 | -ms-overflow-style: none; /* IE and Edge */ 233 | scrollbar-width: none; /* Firefox */ 234 | } 235 | 236 | /* Animation delay utilities */ 237 | .animation-delay-0 { 238 | animation-delay: 0ms; 239 | } 240 | 241 | .animation-delay-100 { 242 | animation-delay: 100ms; 243 | } 244 | 245 | .animation-delay-200 { 246 | animation-delay: 200ms; 247 | } 248 | 249 | .animation-delay-300 { 250 | animation-delay: 300ms; 251 | } 252 | 253 | .animation-delay-400 { 254 | animation-delay: 400ms; 255 | } 256 | 257 | .animation-delay-500 { 258 | animation-delay: 500ms; 259 | } 260 | 261 | /* Chat message animations */ 262 | @keyframes messageSlideIn { 263 | from { 264 | opacity: 0; 265 | transform: translateY(5px); 266 | } 267 | to { 268 | opacity: 1; 269 | transform: translateY(0); 270 | } 271 | } 272 | 273 | .message-animation { 274 | animation: messageSlideIn 0.25s ease-out forwards; 275 | } 276 | 277 | .assistant-message { 278 | position: relative; 279 | } 280 | 281 | .assistant-message::before { 282 | content: ""; 283 | position: absolute; 284 | left: -8px; 285 | top: 24px; 286 | width: 0; 287 | height: 0; 288 | border-top: 8px solid transparent; 289 | border-bottom: 8px solid transparent; 290 | border-right: 8px solid rgba(255, 255, 255, 0.4); 291 | } 292 | 293 | @keyframes typingIndicator { 294 | 0%, 295 | 100% { 296 | opacity: 0.4; 297 | } 298 | 50% { 299 | opacity: 0.8; 300 | } 301 | } 302 | 303 | .typing-indicator span { 304 | display: inline-block; 305 | width: 5px; 306 | height: 5px; 307 | background-color: hsl(var(--primary)); 308 | border-radius: 50%; 309 | margin: 0 2px; 310 | animation: typingIndicator 1s infinite; 311 | } 312 | 313 | .typing-indicator span:nth-child(2) { 314 | animation-delay: 0.2s; 315 | } 316 | 317 | .typing-indicator span:nth-child(3) { 318 | animation-delay: 0.4s; 319 | } 320 | 321 | /* Message hover effects */ 322 | .message-hover-effect { 323 | transition: transform 0.2s ease, box-shadow 0.2s ease; 324 | } 325 | 326 | .message-hover-effect:hover { 327 | transform: translateY(-1px); 328 | box-shadow: 0 3px 10px rgba(0, 0, 0, 0.05); 329 | } 330 | -------------------------------------------------------------------------------- /web/app/guide/layout.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasProvider } from "@/context/canvas-context"; 2 | 3 | export default function CanvasHiloopLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 |
10 | {/* Background dot pattern */} 11 |
12 |
13 |
14 |
15 |
16 | 17 | {/* Magazine page container */} 18 |
19 | {children} 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /web/app/handler/[...stack]/page.tsx: -------------------------------------------------------------------------------- 1 | import { stackServerApp } from "@/stack"; 2 | import { StackHandler } from "@stackframe/stack"; 3 | 4 | export default function Handler(props: unknown) { 5 | return ( 6 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { stackServerApp } from "@/stack"; 2 | import { StackProvider, StackTheme } from "@stackframe/stack"; 3 | import type { Metadata } from "next"; 4 | import { Chakra_Petch, Geist, Geist_Mono } from "next/font/google"; 5 | import "./globals.css"; 6 | 7 | const chakraPetch = Chakra_Petch({ 8 | variable: "--font-chakra-petch", 9 | subsets: ["latin"], 10 | weight: ["300", "400", "500", "600", "700"], 11 | display: "swap", 12 | }); 13 | 14 | const geistSans = Geist({ 15 | variable: "--font-geist-sans", 16 | subsets: ["latin"], 17 | }); 18 | 19 | const geistMono = Geist_Mono({ 20 | variable: "--font-geist-mono", 21 | subsets: ["latin"], 22 | }); 23 | 24 | export const metadata: Metadata = { 25 | title: "Canvas Callback", 26 | description: 27 | "Canvas Callback | A detailed example of how to implement the Canvas with langgraph interrupts", 28 | }; 29 | 30 | export default function RootLayout({ 31 | children, 32 | }: Readonly<{ 33 | children: React.ReactNode; 34 | }>) { 35 | return ( 36 | 37 | 40 | 41 | {children} 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /web/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Plane } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
7 | {/* Retro-modern animated background */} 8 |
9 |
10 | {Array.from({ length: 6 }).map((_, i) => ( 11 |
24 | ))} 25 |
26 | 27 | {/* Main loading animation */} 28 |
29 |
30 |
31 | 32 |
33 | 34 |
35 |
36 | 37 | {/* Text content */} 38 |
39 |

40 | LOADING ADVENTURE 41 |

42 |

43 | Initializing your travel experience 44 |
45 | Please stand by... 46 |

47 |
48 | 49 | {/* Progress bar with modern styling */} 50 |
51 |
52 |
53 | 54 | {/* Modern-retro loading indicators */} 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /web/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Home, Search } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export default function NotFound() { 7 | return ( 8 |
9 | {/* Background dot pattern */} 10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 | 404 21 | 22 |
23 |
24 | 25 |

PAGE NOT FOUND

26 | 27 |

28 | The page you're looking for doesn't exist or has been moved. 29 |

30 | 31 |
32 | 41 | 42 | 52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { BookOpen, Github, Play } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export default function HomePage() { 7 | return ( 8 |
9 | {/* Background dot pattern */} 10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 | {/* Header */} 19 |
20 |

21 | CANVASCALLBACK 22 |

23 |

24 | Open-source implementation of Canvas with human-in-the-loop 25 |

26 |
27 | 28 | {/* Description card */} 29 |
30 |

31 | Canvas Callback demonstrates how to transform AI chat interfaces 32 | into interactive visual workspaces using LangGraph interrupts for 33 | joint problem-solving between users and AI agents. 34 |

35 |
36 | 37 | {/* Large CTAs */} 38 |
39 |
40 |

Canvas Guide

41 |

42 | Learn architecture and implementation patterns for interactive 43 | canvas interfaces. 44 |

45 | 58 |
59 | 60 |
61 |

Interactive Demo

62 |

63 | Try canvas interactions firsthand with an interactive travel 64 | planning AI Agent. 65 |

66 | 79 |
80 |
81 | 82 | {/* Key Features */} 83 |
84 |
85 |
86 |

Canvas UI

87 |

88 | Visual workspace alongside chat interface 89 |

90 |
91 |
92 |

93 | Human-in-the-loop 94 |

95 |

96 | LangGraph interrupts for collecting user input 97 |

98 |
99 |
100 |

Reference Code

101 |

102 | Patterns to adapt to your applications 103 |

104 |
105 |
106 |
107 | 108 | {/* Footer */} 109 |
110 |
111 | 117 | 118 | GitHub Repository 119 | 120 |
121 |
122 |
123 |
124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /web/components/canvas-component.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { BaseMessage } from "@langchain/core/messages"; 4 | import { Interrupt } from "@langchain/langgraph-sdk"; 5 | import { PanelRightClose } from "lucide-react"; 6 | import { useEffect, useState } from "react"; 7 | 8 | import { Chat } from "@/components/chat"; 9 | import { InterruptHandler } from "@/components/interrupt-handler"; 10 | import { TripData } from "@/components/trip-card"; 11 | import { TripDialog } from "@/components/trip-dialog"; 12 | import { Button } from "@/components/ui/button"; 13 | import { Canvas } from "@/components/ui/canvas"; 14 | import { useSidebar } from "@/components/ui/sidebar"; 15 | import { sendInterruptResponse } from "@/context/action"; 16 | import { useCanvasContext } from "@/context/canvas-context"; 17 | import { createTripData } from "@/lib/utils"; 18 | import { AgentState } from "@/types"; 19 | 20 | interface CanvasComponentProps { 21 | chatId: string; 22 | intialMessages: BaseMessage[]; 23 | intialInterruptData?: Interrupt; 24 | initialAgentState?: AgentState; 25 | } 26 | 27 | export function CanvasComponent({ 28 | chatId, 29 | intialMessages, 30 | intialInterruptData, 31 | initialAgentState, 32 | }: CanvasComponentProps) { 33 | const [messages, setMessages] = useState(intialMessages); 34 | const [isStreaming, setIsStreaming] = useState(false); 35 | const [interrupt, setInterrupt] = useState( 36 | intialInterruptData 37 | ); 38 | const [agentState, setAgentState] = useState( 39 | initialAgentState 40 | ); 41 | const { toggleSidebar, state } = useSidebar(); 42 | const { isOpen, toggleCanvas, closeCanvas, isLoading, setLoading } = 43 | useCanvasContext(); 44 | 45 | // Trip dialog state 46 | const [tripDialogOpen, setTripDialogOpen] = useState(false); 47 | const [tripData, setTripData] = useState(null); 48 | 49 | // Auto-open canvas if interrupt data is present 50 | useEffect(() => { 51 | if (interrupt && !isOpen) { 52 | toggleCanvas(); 53 | if (state === "expanded") toggleSidebar(); 54 | } 55 | }, [interrupt]); 56 | 57 | // Show trip dialog when trip data is available 58 | useEffect(() => { 59 | if (agentState?.trips) { 60 | const formattedTripData = createTripData(agentState.trips) as TripData; 61 | setTripData(formattedTripData); 62 | setTripDialogOpen(true); 63 | } 64 | }, [agentState?.trips]); 65 | 66 | return ( 67 |
68 | {/* Sidebar Toggle Button */} 69 | {state === "collapsed" && ( 70 | 78 | )} 79 | 80 | {/* Chat Area */} 81 |
82 | {/* Canvas Toggle Button - only visible when canvas is closed */} 83 | {!isOpen && ( 84 | 88 | )} 89 | 90 | {/* Chat Component */} 91 | 99 |
100 | 101 | {/* Canvas Area */} 102 | {isOpen && ( 103 | 110 | 114 | 115 | 116 | {interrupt ? ( 117 | { 120 | console.log( 121 | "Received command from interrupt handler:", 122 | command 123 | ); 124 | 125 | setLoading(true); 126 | 127 | // Use dedicated interrupt response function 128 | await sendInterruptResponse({ 129 | chatId: chatId ?? "", 130 | interrupt, 131 | setMessages, 132 | setInterrupt, 133 | command, 134 | agentState, 135 | setAgentState, 136 | }); 137 | 138 | setLoading(false); 139 | }} 140 | /> 141 | ) : ( 142 |
143 |

No content to display

144 |
145 | )} 146 |
147 |
148 | )} 149 | 150 | {/* Trip Dialog */} 151 | 156 |
157 | ); 158 | } 159 | -------------------------------------------------------------------------------- /web/components/chat-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserButton } from "@stackframe/stack"; 4 | import { PlusIcon } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Sidebar, 10 | SidebarContent, 11 | SidebarFooter, 12 | SidebarHeader, 13 | SidebarTrigger, 14 | useSidebar, 15 | } from "@/components/ui/sidebar"; 16 | import { useCanvasContext } from "@/context/canvas-context"; 17 | 18 | export function ChatSidebar() { 19 | const router = useRouter(); 20 | const { state, toggleSidebar } = useSidebar(); 21 | const { isOpen, toggleCanvas } = useCanvasContext(); 22 | 23 | // Create a new chat 24 | const handleNewChat = async () => { 25 | try { 26 | if (state === "expanded") toggleSidebar(); // Close the sidebar 27 | if (isOpen) toggleCanvas(); // Close the canvas 28 | router.push(`/n`); // Start a new chat 29 | } catch (error) { 30 | console.error("Failed to create new chat:", error); 31 | } 32 | }; 33 | 34 | return ( 35 | 36 | 37 |
38 |

Canvas Callback

39 | 40 |
41 |
42 | 43 |
44 | 52 |
53 |
54 | 55 |
56 |
57 | © {new Date().getFullYear()} CanvasCallback 58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /web/components/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ImageAttachment } from "@/types"; 4 | import { 5 | AppendMessage, 6 | AssistantRuntimeProvider, 7 | CompositeAttachmentAdapter, 8 | SimpleImageAttachmentAdapter, 9 | TextContentPart, 10 | useExternalMessageConverter, 11 | useExternalStoreRuntime, 12 | } from "@assistant-ui/react"; 13 | import { BaseMessage, HumanMessage } from "@langchain/core/messages"; 14 | import { motion } from "framer-motion"; 15 | import React, { Dispatch, SetStateAction, useEffect } from "react"; 16 | import { v4 as uuidv4 } from "uuid"; 17 | 18 | import { Thread } from "@/components/thread"; 19 | import { 20 | convertLangchainMessages, 21 | convertToOpenAIFormat, 22 | } from "@/lib/convert_messages"; 23 | import { cn, converToBase64 } from "@/lib/utils"; 24 | 25 | import { sendMessage } from "@/context/action"; 26 | import { Interrupt } from "@langchain/langgraph-sdk"; 27 | 28 | export interface ContentComposerChatInterfaceProps { 29 | chatId: string; 30 | messages: BaseMessage[]; 31 | isStreaming: boolean; 32 | setMessages: Dispatch>; 33 | setInterrupt: Dispatch>; 34 | setIsStreaming: Dispatch>; 35 | } 36 | 37 | export function ContentComposerChatInterfaceComponent({ 38 | chatId, 39 | messages, 40 | isStreaming, 41 | setMessages, 42 | setInterrupt, 43 | setIsStreaming, 44 | }: ContentComposerChatInterfaceProps): React.ReactElement { 45 | async function onNew(message: AppendMessage): Promise { 46 | setIsStreaming(true); 47 | 48 | const textContent = 49 | ( 50 | message.content.find((c) => c.type === "text") as 51 | | TextContentPart 52 | | undefined 53 | )?.text || ""; 54 | 55 | const imageData = await Promise.all( 56 | message?.attachments?.map(async (attachment) => { 57 | const imageFiles = attachment.content 58 | .filter( 59 | (item): item is { type: "image"; file: File; image: string } => 60 | item.type === "image" && !!attachment.file && !!item.image 61 | ) 62 | .map(() => attachment.file); 63 | 64 | return Promise.all( 65 | imageFiles.map(async (file): Promise => { 66 | if (!file) { 67 | throw new Error("File is undefined"); 68 | } 69 | const base64Data = await converToBase64(file); 70 | return { 71 | base64: base64Data, 72 | name: file.name, 73 | type: file.type, 74 | displayUrl: URL.createObjectURL(file), 75 | }; 76 | }) 77 | ); 78 | }) ?? [] 79 | ); 80 | 81 | const flattenedImages: ImageAttachment[] = imageData.flat(); 82 | 83 | try { 84 | const humanMessage = new HumanMessage({ 85 | content: 86 | flattenedImages.length > 0 87 | ? [ 88 | { 89 | type: "text", 90 | text: textContent, 91 | }, 92 | ...flattenedImages.map((image) => ({ 93 | type: "image_url", 94 | image_url: { 95 | url: `${image.base64}`, 96 | }, 97 | })), 98 | ].filter(Boolean) 99 | : textContent, 100 | id: uuidv4(), 101 | }); 102 | 103 | setMessages((prevMessages) => [...prevMessages, humanMessage]); 104 | 105 | await sendMessage({ 106 | messages: [convertToOpenAIFormat(humanMessage)], 107 | chatId: chatId, 108 | setMessages, 109 | setInterrupt, 110 | }); 111 | } finally { 112 | setIsStreaming(false); 113 | } 114 | } 115 | 116 | const threadMessages = useExternalMessageConverter({ 117 | callback: convertLangchainMessages, 118 | messages: messages, 119 | isRunning: isStreaming, 120 | }); 121 | 122 | const runtime = useExternalStoreRuntime({ 123 | messages: threadMessages, 124 | isRunning: isStreaming, 125 | onNew, 126 | adapters: { 127 | attachments: new CompositeAttachmentAdapter([ 128 | new SimpleImageAttachmentAdapter(), 129 | ]), 130 | }, 131 | }); 132 | 133 | // Add subtle background animation effect 134 | useEffect(() => { 135 | const handleMouseMove = (e: MouseEvent) => { 136 | const x = e.clientX / window.innerWidth; 137 | const y = e.clientY / window.innerHeight; 138 | 139 | document.documentElement.style.setProperty("--mouse-x", x.toString()); 140 | document.documentElement.style.setProperty("--mouse-y", y.toString()); 141 | }; 142 | 143 | window.addEventListener("mousemove", handleMouseMove); 144 | 145 | return () => { 146 | window.removeEventListener("mousemove", handleMouseMove); 147 | }; 148 | }, []); 149 | 150 | return ( 151 | 157 | {/* Subtle gradient background that follows mouse position */} 158 |
159 | 160 |
167 | 168 | 169 | 170 |
171 |
172 | ); 173 | } 174 | 175 | export const Chat = React.memo(ContentComposerChatInterfaceComponent); 176 | -------------------------------------------------------------------------------- /web/components/interrupt-handler.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Command, Interrupt } from "@langchain/langgraph-sdk"; 4 | 5 | import { ActivitySelector } from "./interrupts/activity-selector"; 6 | import { DateSelector } from "./interrupts/date-selector"; 7 | import { DestinationSelector } from "./interrupts/destination-selector"; 8 | 9 | export interface InterruptHandlerProps { 10 | interrupt: Interrupt; 11 | onSubmit?: (command: Command) => void; 12 | } 13 | 14 | export function InterruptHandler({ 15 | interrupt, 16 | onSubmit, 17 | }: InterruptHandlerProps) { 18 | // Extract the type from interrupt data 19 | const type = (interrupt?.value as any)?.type || "unknown"; 20 | // console.log("InterruptHandler type:", type); 21 | 22 | // Route to the appropriate interrupt handler based on type 23 | switch (type) { 24 | case "destination": 25 | return ( 26 | 30 | ); 31 | case "dates": 32 | return ( 33 | 37 | ); 38 | case "activities": 39 | return ( 40 | 44 | ); 45 | 46 | default: 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/components/interrupts/activity-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | import type { Command, Interrupt } from "@langchain/langgraph-sdk"; 6 | import { motion } from "framer-motion"; 7 | import { 8 | Check, 9 | ChevronLeft, 10 | ChevronRight, 11 | Landmark, 12 | Map, 13 | Mountain, 14 | LibraryIcon as Museum, 15 | Music, 16 | ShoppingBag, 17 | TreesIcon as Tree, 18 | Users, 19 | Utensils, 20 | Waves, 21 | X, 22 | } from "lucide-react"; 23 | import Image from "next/image"; 24 | import { useRef, useState } from "react"; 25 | 26 | export interface ActivitySelectorProps { 27 | interrupt: Interrupt; 28 | onSubmit?: (command: Command) => void; 29 | } 30 | 31 | // Enhanced activity data with rich details 32 | const activityData = [ 33 | { 34 | id: "museums", 35 | name: "Museums and culture", 36 | image: 37 | "https://images.unsplash.com/photo-1564399579883-451a5d44ec08?auto=format&fit=crop&q=80&w=1000", 38 | icon: Museum, 39 | color: "from-primary", 40 | description: 41 | "Immerse yourself in local art, history, and cultural exhibits", 42 | mood: "Inspiring and enlightening", 43 | sound: "Quiet contemplation with occasional tour guide voices", 44 | }, 45 | { 46 | id: "outdoor", 47 | name: "Outdoor adventures", 48 | image: 49 | "https://images.unsplash.com/photo-1559521783-1d1599583485?auto=format&fit=crop&q=80&w=1000", 50 | icon: Mountain, 51 | color: "from-primary/90", 52 | description: "Feel the adrenaline rush of exciting outdoor activities", 53 | mood: "Exhilarating and energizing", 54 | sound: "Wind rushing past, excited voices, and nature's call", 55 | }, 56 | { 57 | id: "beaches", 58 | name: "Beaches and water activities", 59 | image: 60 | "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&q=80&w=1000", 61 | icon: Waves, 62 | color: "from-primary/80", 63 | description: "Feel the sand between your toes and the refreshing water", 64 | mood: "Relaxing and rejuvenating", 65 | sound: "Gentle waves lapping at the shore, seagulls calling", 66 | }, 67 | { 68 | id: "hiking", 69 | name: "Hiking and nature", 70 | image: 71 | "https://images.unsplash.com/photo-1551632811-561732d1e306?auto=format&fit=crop&q=80&w=1000", 72 | icon: Tree, 73 | color: "from-primary/95", 74 | description: "Breathe in the fresh air as you explore natural wonders", 75 | mood: "Peaceful and grounding", 76 | sound: "Rustling leaves, birdsong, and the crunch of the trail", 77 | }, 78 | { 79 | id: "food", 80 | name: "Food and culinary experiences", 81 | image: 82 | "https://images.unsplash.com/photo-1555939594-58d7cb561ad1?auto=format&fit=crop&q=80&w=1000", 83 | icon: Utensils, 84 | color: "from-primary", 85 | description: "Savor the flavors and aromas of local cuisine", 86 | mood: "Delightful and satisfying", 87 | sound: "Sizzling pans, clinking glasses, and happy conversation", 88 | }, 89 | { 90 | id: "shopping", 91 | name: "Shopping", 92 | image: 93 | "https://images.unsplash.com/photo-1555529669-e69e7aa0ba9a?auto=format&fit=crop&q=80&w=1000", 94 | icon: ShoppingBag, 95 | color: "from-primary/70", 96 | description: "Hunt for treasures and unique items to remember your trip", 97 | mood: "Exciting and rewarding", 98 | sound: "Bustling markets, rustling bags, and street performers", 99 | }, 100 | { 101 | id: "historical", 102 | name: "Historical sites", 103 | image: 104 | "https://images.unsplash.com/photo-1526129318478-62ed807ebdf9?auto=format&fit=crop&q=80&w=1000", 105 | icon: Landmark, 106 | color: "from-primary/95", 107 | description: "Step back in time and connect with the past", 108 | mood: "Fascinating and thought-provoking", 109 | sound: "Echoing footsteps in ancient halls and whispers of history", 110 | }, 111 | { 112 | id: "tours", 113 | name: "Local tours", 114 | image: 115 | "https://images.unsplash.com/photo-1520998116484-6eeb2f72b5b9?auto=format&fit=crop&q=80&w=1000", 116 | icon: Map, 117 | color: "from-primary/85", 118 | description: 119 | "See the destination through the eyes of those who know it best", 120 | mood: "Informative and authentic", 121 | sound: "Enthusiastic guides sharing stories and local secrets", 122 | }, 123 | { 124 | id: "nightlife", 125 | name: "Nightlife", 126 | image: 127 | "https://images.unsplash.com/photo-1566417713940-fe7c737a9ef2?auto=format&fit=crop&q=80&w=1000", 128 | icon: Music, 129 | color: "from-primary/90", 130 | description: "Experience the energy and excitement after dark", 131 | mood: "Vibrant and lively", 132 | sound: "Pulsing music, laughter, and the buzz of conversation", 133 | }, 134 | { 135 | id: "family", 136 | name: "Family-friendly activities", 137 | image: 138 | "https://images.unsplash.com/photo-1608889825103-eb5ed706fc64?auto=format&fit=crop&q=80&w=1000", 139 | icon: Users, 140 | color: "from-primary/80", 141 | description: 142 | "Create memories that everyone from kids to grandparents will cherish", 143 | mood: "Joyful and heartwarming", 144 | sound: "Children's laughter, playful shouts, and family chatter", 145 | }, 146 | ]; 147 | 148 | export function ActivitySelector({ 149 | interrupt, 150 | onSubmit, 151 | }: ActivitySelectorProps) { 152 | const [selectedActivities, setSelectedActivities] = useState([]); 153 | const horizontalScrollRef = useRef(null); 154 | 155 | // Extract the question from interrupt data 156 | const data = 157 | (interrupt?.value as any)?.data || 158 | "What activities would you like to experience?"; 159 | 160 | const toggleActivity = (activity: string) => { 161 | setSelectedActivities((current) => 162 | current.includes(activity) 163 | ? current.filter((a) => a !== activity) 164 | : [...current, activity] 165 | ); 166 | }; 167 | 168 | const handleSubmit = () => { 169 | if (selectedActivities.length > 0 && onSubmit) { 170 | const payload: Command = { 171 | goto: undefined, 172 | resume: { 173 | activities: selectedActivities, 174 | }, 175 | update: {}, 176 | }; 177 | onSubmit(payload); 178 | } 179 | }; 180 | 181 | const scrollHorizontal = (direction: "left" | "right") => { 182 | if (horizontalScrollRef.current) { 183 | const { current } = horizontalScrollRef; 184 | const scrollAmount = current.clientWidth * 0.8; 185 | 186 | if (direction === "left") { 187 | current.scrollBy({ left: -scrollAmount, behavior: "smooth" }); 188 | } else { 189 | current.scrollBy({ left: scrollAmount, behavior: "smooth" }); 190 | } 191 | } 192 | }; 193 | 194 | return ( 195 |
196 | {/* Subtle gradient overlay */} 197 |
198 | 199 |
200 |
201 |
202 |

203 | {data} 204 |

205 |

206 | Select the experiences that resonate with you 207 |

208 |
209 |
210 | 211 | {/* Horizontal Scroll View */} 212 | 219 | {/* Navigation buttons */} 220 |
221 | 230 |
231 | 232 |
233 | 242 |
243 | 244 |
253 | {activityData.map((activity) => { 254 | const isSelected = selectedActivities.includes(activity.name); 255 | const Icon = activity.icon; 256 | 257 | return ( 258 | toggleActivity(activity.name)} 268 | whileHover={{ scale: 1.02, y: -2 }} 269 | transition={{ type: "spring", stiffness: 400, damping: 25 }} 270 | > 271 |
272 |
273 | {activity.name} 279 |
285 | 286 | {/* Selection indicator */} 287 |
288 | 293 | 294 | 295 |
296 | 297 | {/* Activity icon */} 298 |
299 | 309 | 310 | 311 |
312 | 313 |
314 |

315 | {activity.name} 316 |

317 |
318 |
319 | 320 |
321 |

322 | {activity.description} 323 |

324 | 325 | {/* Always visible content */} 326 |
327 |

328 | Feel:{" "} 329 | {activity.mood} 330 |

331 |

332 | Hear:{" "} 333 | {activity.sound} 334 |

335 |
336 | 337 | 360 |
361 |
362 | ); 363 | })} 364 |
365 |
366 | 367 | {/* Selected activities summary */} 368 |
369 |
370 |

371 | {selectedActivities.length === 0 372 | ? "No activities selected yet" 373 | : `${selectedActivities.length} ${ 374 | selectedActivities.length === 1 ? "activity" : "activities" 375 | } selected`} 376 |

377 | 378 | {selectedActivities.length > 0 && ( 379 | 387 | )} 388 |
389 | 390 | {selectedActivities.length > 0 && ( 391 | 403 | {selectedActivities.map((activity) => { 404 | const activityDataFound = activityData.find( 405 | (a) => a.name === activity 406 | ); 407 | const Icon = activityDataFound?.icon || Museum; 408 | 409 | return ( 410 | 419 | 420 | {activity} 421 | 431 | 432 | ); 433 | })} 434 | 435 | )} 436 |
437 | 438 | {/* Submit button */} 439 |
440 | 457 |
458 |
459 |
460 | ); 461 | } 462 | -------------------------------------------------------------------------------- /web/components/interrupts/date-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Command, Interrupt } from "@langchain/langgraph-sdk"; 4 | import { differenceInDays, format } from "date-fns"; 5 | import { motion } from "framer-motion"; 6 | import { CalendarIcon } from "lucide-react"; 7 | import { useEffect, useState } from "react"; 8 | import type { DateRange } from "react-day-picker"; 9 | 10 | import { Calendar } from "@/components/ui/calendar"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | export interface DateSelectorProps { 14 | interrupt: Interrupt; 15 | onSubmit?: (command: Command) => void; 16 | } 17 | 18 | export function DateSelector({ interrupt, onSubmit }: DateSelectorProps) { 19 | const [dateRange, setDateRange] = useState(undefined); 20 | const [numberOfMonths, setNumberOfMonths] = useState(2); 21 | 22 | // Extract the question from interrupt data 23 | const data = 24 | (interrupt?.value as any)?.data || "When would you like to travel?"; 25 | 26 | // Calculate trip duration 27 | const tripDuration = 28 | dateRange?.from && dateRange?.to 29 | ? differenceInDays(dateRange.to, dateRange.from) + 1 30 | : 0; 31 | 32 | // Handle responsive calendar 33 | useEffect(() => { 34 | const handleResize = () => { 35 | if (window.innerWidth < 640) { 36 | setNumberOfMonths(1); 37 | } else { 38 | setNumberOfMonths(2); 39 | } 40 | }; 41 | 42 | handleResize(); 43 | window.addEventListener("resize", handleResize); 44 | return () => window.removeEventListener("resize", handleResize); 45 | }, []); 46 | 47 | const handleSubmit = () => { 48 | if (dateRange?.from) { 49 | if (onSubmit) { 50 | const payload: Command = { 51 | goto: undefined, 52 | resume: { 53 | dates: { 54 | startDate: dateRange.from.toISOString().split("T")[0], 55 | endDate: dateRange.to 56 | ? dateRange.to.toISOString().split("T")[0] 57 | : dateRange.from.toISOString().split("T")[0], 58 | }, 59 | }, 60 | update: {}, 61 | }; 62 | onSubmit(payload); 63 | } 64 | } 65 | }; 66 | 67 | return ( 68 |
69 |
70 | 71 |
72 | 77 | 83 | 84 | {data} 85 | 86 | 92 | Select your travel dates by clicking on the calendar. Click once for 93 | your arrival date, then click again for your departure date. 94 | 95 | 96 | 97 | {/* Large Calendar */} 98 | 104 | 168 | 169 | 170 | {/* Selected date summary */} 171 | {dateRange?.from && ( 172 | 178 |
179 | {dateRange.to && dateRange.from !== dateRange.to ? ( 180 | <> 181 | 182 | {format(dateRange.from, "MMMM d, yyyy")} 183 | 184 | to 185 | 186 | {format(dateRange.to, "MMMM d, yyyy")} 187 | 188 |
189 | {tripDuration} {tripDuration === 1 ? "day" : "days"} total 190 |
191 | 192 | ) : ( 193 | <> 194 | 195 | {format(dateRange.from, "MMMM d, yyyy")} 196 | 197 |
Single day
198 | 199 | )} 200 |
201 |
202 | )} 203 | 204 | {/* Submit button */} 205 | 211 | 225 | 226 |
227 |
228 | ); 229 | } 230 | -------------------------------------------------------------------------------- /web/components/interrupts/destination-selector.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Command, Interrupt } from "@langchain/langgraph-sdk"; 4 | import { Search, X } from "lucide-react"; 5 | import Script from "next/script"; 6 | import { useEffect, useRef, useState } from "react"; 7 | 8 | import { Button } from "@/components/ui/button"; 9 | import { Input } from "@/components/ui/input"; 10 | 11 | // Add Leaflet type declarations 12 | declare global { 13 | interface Window { 14 | L: any; 15 | } 16 | } 17 | 18 | export interface DestinationSelectorProps { 19 | interrupt: Interrupt; 20 | onSubmit?: (command: Command) => void; 21 | } 22 | 23 | export function DestinationSelector({ 24 | interrupt, 25 | onSubmit, 26 | }: DestinationSelectorProps) { 27 | const [selectedLocation, setSelectedLocation] = useState<{ 28 | lat: number; 29 | lng: number; 30 | display_name?: string; 31 | } | null>(null); 32 | const [searchQuery, setSearchQuery] = useState(""); 33 | const [searchResults, setSearchResults] = useState([]); 34 | const [showSearchResults, setShowSearchResults] = useState(false); 35 | const mapRef = useRef(null); 36 | const mapContainerRef = useRef(null); 37 | const markerRef = useRef(null); 38 | const geocoderRef = useRef(null); 39 | 40 | // Extract the question from interrupt data 41 | const data = (interrupt?.value as any)?.data || "No data available"; 42 | 43 | useEffect(() => { 44 | if (mapContainerRef.current) { 45 | // Wait for Leaflet scripts to load 46 | if (typeof window !== "undefined" && window.L) { 47 | initializeMap(); 48 | } 49 | } 50 | 51 | // Ensure map resizes correctly when displayed 52 | return () => { 53 | if (mapRef.current) { 54 | mapRef.current.remove(); 55 | mapRef.current = null; 56 | } 57 | }; 58 | }, []); 59 | 60 | const initializeMap = () => { 61 | const L = window.L; 62 | if (!L) return; // Ensure Leaflet is loaded 63 | 64 | // Initialize map 65 | if (mapRef.current) return; // Avoid re-initializing 66 | 67 | // Create map with zoom controls in bottom-right 68 | mapRef.current = L.map(mapContainerRef.current, { 69 | zoomControl: false, // Disable default zoom control 70 | }).setView([20, 0], 2); 71 | 72 | // Add zoom control to bottom right 73 | L.control 74 | .zoom({ 75 | position: "bottomright", 76 | }) 77 | .addTo(mapRef.current); 78 | 79 | // Add OpenStreetMap tile layer 80 | L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { 81 | attribution: 82 | '© OpenStreetMap contributors', 83 | }).addTo(mapRef.current); 84 | 85 | // Check if geocoder is available 86 | if (typeof L.Control.Geocoder === "function") { 87 | // Initialize the geocoder with proper constructor 88 | geocoderRef.current = new L.Control.Geocoder({ 89 | defaultMarkGeocode: false, 90 | position: "topleft", 91 | placeholder: "Search for a location...", 92 | errorMessage: "Nothing found.", 93 | showResultIcons: true, 94 | suggestMinLength: 3, 95 | suggestTimeout: 250, 96 | }).addTo(mapRef.current); 97 | 98 | // Handle geocoder results 99 | geocoderRef.current.on( 100 | "markgeocode", 101 | (e: { 102 | geocode: { center: { lat: number; lng: number }; name: string }; 103 | }) => { 104 | const { center, name } = e.geocode; 105 | setSelectedLocation({ 106 | lat: center.lat, 107 | lng: center.lng, 108 | display_name: name, 109 | }); 110 | 111 | // Set marker at the selected location 112 | if (markerRef.current) { 113 | // Remove from map if it's already there 114 | if (mapRef.current.hasLayer(markerRef.current)) { 115 | markerRef.current.setLatLng(center); 116 | } else { 117 | markerRef.current.setLatLng(center).addTo(mapRef.current); 118 | } 119 | 120 | // Update popup content 121 | markerRef.current.unbindPopup(); 122 | markerRef.current 123 | .bindPopup(`Selected Location:
${name}`) 124 | .openPopup(); 125 | } 126 | 127 | // Fly to the location 128 | mapRef.current.flyTo(center, 13); 129 | } 130 | ); 131 | } else { 132 | console.warn("Leaflet Geocoder plugin not available"); 133 | // Continuing without the geocoder functionality 134 | } 135 | 136 | // Set up initial marker if needed - make it NOT draggable 137 | if (!markerRef.current) { 138 | // Create a custom icon for better visibility 139 | const customIcon = L.icon({ 140 | iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", 141 | iconSize: [25, 41], 142 | iconAnchor: [12, 41], 143 | popupAnchor: [1, -34], 144 | shadowUrl: 145 | "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", 146 | shadowSize: [41, 41], 147 | }); 148 | 149 | // Add a marker at the current view center but don't show it yet 150 | markerRef.current = L.marker(mapRef.current.getCenter(), { 151 | draggable: false, // Remove dragging functionality 152 | icon: customIcon, 153 | }); 154 | } 155 | 156 | // Handle map click events to set marker 157 | mapRef.current.on( 158 | "click", 159 | (e: { latlng: { lat: number; lng: number } }) => { 160 | const { lat, lng } = e.latlng; 161 | setSelectedLocation({ 162 | lat, 163 | lng, 164 | }); 165 | 166 | // Set marker at the clicked location 167 | if (markerRef.current) { 168 | // If marker is already on the map, just move it 169 | if (mapRef.current.hasLayer(markerRef.current)) { 170 | markerRef.current.setLatLng(e.latlng); 171 | } else { 172 | // If not, add it to the map 173 | markerRef.current.setLatLng(e.latlng).addTo(mapRef.current); 174 | } 175 | 176 | // Show loading popup while we get the location name 177 | markerRef.current.unbindPopup(); 178 | markerRef.current 179 | .bindPopup("Loading location information...") 180 | .openPopup(); 181 | 182 | // Add a highlight effect around the marker 183 | const highlightCircle = L.circle(e.latlng, { 184 | color: "rgba(66, 133, 244, 0.3)", 185 | fillColor: "rgba(66, 133, 244, 0.1)", 186 | fillOpacity: 0.5, 187 | radius: 200, 188 | }).addTo(mapRef.current); 189 | 190 | // Remove highlight after 2 seconds 191 | setTimeout(() => { 192 | if (mapRef.current.hasLayer(highlightCircle)) { 193 | mapRef.current.removeLayer(highlightCircle); 194 | } 195 | }, 2000); 196 | } 197 | 198 | // Center map on the selected location with appropriate zoom 199 | mapRef.current.setView(e.latlng, 15); 200 | 201 | // Reverse geocode to get location name 202 | fetch( 203 | `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1` 204 | ) 205 | .then((response) => response.json()) 206 | .then((data) => { 207 | setSelectedLocation((prev) => ({ 208 | ...prev!, 209 | display_name: data.display_name, 210 | })); 211 | 212 | // Update popup with location name 213 | if (markerRef.current && data.display_name) { 214 | markerRef.current.unbindPopup(); 215 | markerRef.current 216 | .bindPopup(`Selected Location:
${data.display_name}`) 217 | .openPopup(); 218 | } 219 | }) 220 | .catch((error) => console.error("Error:", error)); 221 | } 222 | ); 223 | 224 | // Make sure the map renders correctly 225 | setTimeout(() => { 226 | mapRef.current.invalidateSize(); 227 | }, 100); 228 | }; 229 | 230 | const handleMapSearch = async (e: React.FormEvent) => { 231 | e.preventDefault(); 232 | 233 | if (!searchQuery.trim()) return; 234 | 235 | try { 236 | const response = await fetch( 237 | `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( 238 | searchQuery 239 | )}&limit=5` 240 | ); 241 | const data = await response.json(); 242 | setSearchResults(data); 243 | setShowSearchResults(true); 244 | } catch (error) { 245 | console.error("Error searching for location:", error); 246 | } 247 | }; 248 | 249 | const selectSearchResult = (result: any) => { 250 | const L = window.L; 251 | const latlng = [parseFloat(result.lat), parseFloat(result.lon)]; 252 | 253 | setSelectedLocation({ 254 | lat: parseFloat(result.lat), 255 | lng: parseFloat(result.lon), 256 | display_name: result.display_name, 257 | }); 258 | 259 | // Set marker at the selected location 260 | if (markerRef.current) { 261 | // If marker is already on the map, just move it 262 | if (mapRef.current.hasLayer(markerRef.current)) { 263 | markerRef.current.setLatLng(latlng); 264 | } else { 265 | // If not, add it to the map 266 | markerRef.current.setLatLng(latlng).addTo(mapRef.current); 267 | } 268 | 269 | // Add popup to the marker 270 | markerRef.current.unbindPopup(); 271 | markerRef.current 272 | .bindPopup(`Selected Location:
${result.display_name}`) 273 | .openPopup(); 274 | 275 | // Add a highlight effect around the marker 276 | const highlightCircle = L.circle(latlng, { 277 | color: "rgba(66, 133, 244, 0.3)", 278 | fillColor: "rgba(66, 133, 244, 0.1)", 279 | fillOpacity: 0.5, 280 | radius: 200, 281 | }).addTo(mapRef.current); 282 | 283 | // Remove highlight after 2 seconds 284 | setTimeout(() => { 285 | if (mapRef.current.hasLayer(highlightCircle)) { 286 | mapRef.current.removeLayer(highlightCircle); 287 | } 288 | }, 2000); 289 | } 290 | 291 | // Fly to the location with a smooth animation 292 | mapRef.current.flyTo(latlng, 15, { 293 | duration: 1.5, 294 | easeLinearity: 0.25, 295 | }); 296 | 297 | // Clear search 298 | setSearchQuery(""); 299 | setShowSearchResults(false); 300 | }; 301 | 302 | const handleSubmit = (e: React.FormEvent) => { 303 | e.preventDefault(); 304 | 305 | if (selectedLocation) { 306 | const locationString = 307 | selectedLocation.display_name || 308 | `Latitude: ${selectedLocation.lat}, Longitude: ${selectedLocation.lng}`; 309 | 310 | if (onSubmit) { 311 | const payload: Command = { 312 | goto: undefined, 313 | resume: { 314 | destination: locationString, 315 | }, 316 | update: {}, 317 | }; 318 | onSubmit(payload); 319 | } 320 | } 321 | }; 322 | 323 | return ( 324 | <> 325 | {/* Load Leaflet CSS and JS */} 326 |