first commit
This commit is contained in:
102
src/features/copilot/CopilotProvider.tsx
Normal file
102
src/features/copilot/CopilotProvider.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'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;
|
||||
}
|
||||
Reference in New Issue
Block a user