Files
loyaly-merchant/src/features/copilot/CopilotProvider.tsx
2026-08-05 15:26:50 +05:30

103 lines
3.0 KiB
TypeScript

'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<CopilotValue | null>(null);
export function CopilotProvider({children}: {children: React.ReactNode}) {
const [tab, setTab] = useState<CopilotTab>('ai');
const [isOpen, setIsOpen] = useState(true);
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 <CopilotContext value={value}>{children}</CopilotContext>;
}
export function useCopilot(): CopilotValue {
const ctx = useContext(CopilotContext);
if (!ctx) {
throw new Error('useCopilot must be used inside <CopilotProvider>');
}
return ctx;
}