'use client'; import {createContext, useCallback, useContext, useMemo, useState} from 'react'; /** * All Copilot state lives here, and this provider is mounted in * app/providers.tsx — ABOVE the route tree. * * Two things force that placement: * 1. Route changes. The panel itself sits in (workspace)/layout.tsx, which the * App Router preserves across sibling navigations, so it would survive * /dashboard → /staff on its own. * 2. The tablet breakpoint. Crossing it swaps the inline LayoutPanel for a * slide-over, which genuinely unmounts the panel. Holding state up here * means presentation can change without losing a half-typed message. * * The panel is a pure view over this context. Nothing below it owns state. */ export type CopilotTab = 'ai' | 'analytics' | 'chat'; export interface ChatMessage { id: string; sender: 'user' | 'assistant'; text: string; at: string; } interface CopilotValue { tab: CopilotTab; setTab: (t: CopilotTab) => void; isOpen: boolean; setIsOpen: (v: boolean) => void; toggle: () => void; /** Slide-over visibility, used below the laptop breakpoint. */ isSlideOverOpen: boolean; setSlideOverOpen: (v: boolean) => void; messages: ChatMessage[]; /** The in-progress composer value. Kept here so navigation never drops it. */ draft: string; setDraft: (v: string) => void; send: (text: string) => void; isStreaming: boolean; } const CopilotContext = createContext(null); export function CopilotProvider({children}: {children: React.ReactNode}) { const [tab, setTab] = useState('ai'); const [isOpen, setIsOpen] = useState(true); const [isSlideOverOpen, setSlideOverOpen] = useState(false); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); const [isStreaming] = useState(false); const send = useCallback((text: string) => { const trimmed = text.trim(); if (!trimmed) return; setMessages((prev) => [ ...prev, { id: `m${prev.length + 1}`, sender: 'user', text: trimmed, // Timestamps are assigned on send (a user event), never during render — // Date.now() in a render path is a hydration mismatch waiting to happen. at: new Date().toISOString(), }, ]); setDraft(''); }, []); const toggle = useCallback(() => setIsOpen((v) => !v), []); const value = useMemo( () => ({ tab, setTab, isOpen, setIsOpen, toggle, isSlideOverOpen, setSlideOverOpen, messages, draft, setDraft, send, isStreaming, }), [tab, isOpen, isSlideOverOpen, messages, draft, send, toggle, isStreaming], ); return {children}; } export function useCopilot(): CopilotValue { const ctx = useContext(CopilotContext); if (!ctx) { throw new Error('useCopilot must be used inside '); } return ctx; }