103 lines
3.0 KiB
TypeScript
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;
|
|
}
|