/** * AiChatPage — app shell with a chat card on the right (mirrors Miyagi's * create-mode chat layout). * * Layout: * [ app card, flex-1 ] [gap] [ chat card ] * * The chat card has a header bar at the top with the editable title on the * left and three action icons on the right: New chat, History, Close. * Chat history is a slide-in overlay (not an inline rail) — clicking the * History icon overlays the chat panel with a list of past conversations. * When the chat is closed, a single floating button at the top-right of * the page reopens it. * * - Chat card: width animates 0 ↔ chatWidth. Inner content is absolutely * positioned at fixed width so the panel doesn't reflow during the * width animation — it just gets clipped by the card's overflow. * - Resize handle sits on the chat card's left edge. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { History, PanelRightClose, PanelRightOpen, Pencil, Plus, X, } from 'lucide-react' import { useAuth, AuthOverlay, getAuthToken, useQuery } from 'deepspace' // Paths resolve post-install (page → src/pages/, components → src/components/). import { ChatPanel } from '../components/ChatPanel' const CHAT_W_MIN = 320 const CHAT_W_MAX = 720 const CHAT_W_DEFAULT = 380 const STORAGE_OPEN = 'ai-chat-open' const STORAGE_WIDTH = 'ai-chat-width' const EASE = 'cubic-bezier(0.16, 1, 0.3, 1)' interface ChatRow { userId: string title?: string } function loadOpen(): boolean { try { return localStorage.getItem(STORAGE_OPEN) !== '0' } catch { return true } } function loadWidth(): number { try { const v = parseInt(localStorage.getItem(STORAGE_WIDTH) ?? '', 10) if (!isNaN(v) && v >= CHAT_W_MIN && v <= CHAT_W_MAX) return v } catch { /* ignore */ } return CHAT_W_DEFAULT } // Compact relative timestamp: now / 5m / 3h / 2d / 3w. Falls back to '' when // the input isn't a parseable ISO string. function formatRelative(ts?: string): string { if (!ts) return '' const t = Date.parse(ts) if (Number.isNaN(t)) return '' const diff = Math.max(0, Date.now() - t) if (diff < 60_000) return 'now' const m = Math.floor(diff / 60_000) if (m < 60) return `${m}m` const h = Math.floor(m / 60) if (h < 24) return `${h}h` const d = Math.floor(h / 24) if (d < 7) return `${d}d` return `${Math.floor(d / 7)}w` } export default function AiChatPage() { const { isLoaded, isSignedIn, userId } = useAuth() const [showAuth, setShowAuth] = useState(false) const [activeChatId, setActiveChatId] = useState(null) const [chatOpen, setChatOpen] = useState(loadOpen) const [chatWidth, setChatWidth] = useState(loadWidth) const [historyOpen, setHistoryOpen] = useState(false) const [dragging, setDragging] = useState(false) const [creatingChat, setCreatingChat] = useState(false) const [createError, setCreateError] = useState(null) // Delete/rename failures live apart from createError: its Retry re-runs // create, which is the wrong offer after a failed delete or rename. const [actionError, setActionError] = useState(null) const chatCardRef = useRef(null) useEffect(() => { try { localStorage.setItem(STORAGE_OPEN, chatOpen ? '1' : '0') } catch { /* ignore */ } }, [chatOpen]) useEffect(() => { try { localStorage.setItem(STORAGE_WIDTH, String(chatWidth)) } catch { /* ignore */ } }, [chatWidth]) const { records: chatsRaw } = useQuery('ai-chats', { where: { userId: userId ?? '__none__' }, orderBy: 'updatedAt', orderDir: 'desc', limit: 50, }) // Re-sort newest-first on the client. The SDK's useQuery applies orderBy // server-side at the initial fetch but appends WebSocket-broadcasted // inserts to the tail of the local cache, so without this a freshly- // created chat would land at the bottom until the next page refresh. const chats = useMemo(() => { return [...chatsRaw].sort((a, b) => { const aT = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0 const bT = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0 return bT - aT }) }, [chatsRaw]) const activeChat = useMemo( () => chats.find((c) => c.recordId === activeChatId) ?? null, [chats, activeChatId], ) const activeTitle = (activeChat?.data.title ?? '').trim() || (activeChatId ? 'Untitled' : 'New chat') // Resize drag — instant width updates while dragging (no transition). // Window blur and visibility loss force-end the drag so the body cursor // never gets stuck on `col-resize` if the user alt-tabs mid-drag. useEffect(() => { if (!dragging) return function onMove(e: MouseEvent) { if (!chatCardRef.current) return // Width is the distance from mouse-x to the chat card's right edge. // The rail sits to the right of this card on the page background and // is not part of the chat width any more. const rect = chatCardRef.current.getBoundingClientRect() const raw = rect.right - e.clientX setChatWidth(Math.max(CHAT_W_MIN, Math.min(CHAT_W_MAX, raw))) } function endDrag() { setDragging(false) } document.addEventListener('mousemove', onMove) document.addEventListener('mouseup', endDrag) window.addEventListener('blur', endDrag) document.addEventListener('visibilitychange', endDrag) const prevCursor = document.body.style.cursor const prevSelect = document.body.style.userSelect document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' return () => { document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', endDrag) window.removeEventListener('blur', endDrag) document.removeEventListener('visibilitychange', endDrag) document.body.style.cursor = prevCursor document.body.style.userSelect = prevSelect } }, [dragging]) const handleSelect = useCallback((id: string) => { setActiveChatId(id) setChatOpen(true) setHistoryOpen(false) }, []) // Stable callback for the history overlay's onClose. Without this, an // inline arrow would change identity on every parent re-render and the // overlay's focus-management effect (which lists onClose in its deps) // would re-fire on every WS broadcast — losing the user's tab position. const closeHistory = useCallback(() => setHistoryOpen(false), []) const handleNew = useCallback(async () => { // Eager create: row appears in the sidebar at click-time. We set chatId // to null up front so the panel renders the empty state immediately, // and flip `creatingChat` so the panel's input is suspended — without // that gate a fast typist could send before our POST resolves and the // panel would also auto-create, spawning a second chat. setActiveChatId(null) setChatOpen(true) setHistoryOpen(false) setCreateError(null) setCreatingChat(true) try { const token = await getAuthToken() const headers: Record = { 'Content-Type': 'application/json' } if (token) headers.Authorization = `Bearer ${token}` const res = await fetch('/api/ai/chats', { method: 'POST', headers }) if (!res.ok) throw new Error(`create chat failed: ${res.status}`) const data = (await res.json()) as { chat?: { recordId?: string } } if (data.chat?.recordId) setActiveChatId(data.chat.recordId) } catch (err) { console.error('[ai-chat-page] create chat failed:', err) setCreateError(err instanceof Error ? err.message : 'Failed to create chat') } finally { setCreatingChat(false) } }, []) const handleDelete = useCallback(async (id: string) => { // Don't clear `activeChatId` until the DELETE actually succeeds: if it // fails, the row stays in the user's sidebar (because useQuery still // reflects it server-side) and we don't want the UI to mislead the user // into thinking it's gone. The chat-switch effect in ChatPanel aborts // any in-flight stream when activeChatId flips id→null, so the orphan // assistant write we'd otherwise produce on cascade-delete is prevented // by F1 (abort on id→null) — not by ordering this call before the fetch. setActionError(null) try { const token = await getAuthToken() const headers: Record = {} if (token) headers.Authorization = `Bearer ${token}` const res = await fetch(`/api/ai/chats/${id}`, { method: 'DELETE', headers }) if (!res.ok) throw new Error(`delete failed: ${res.status}`) setActiveChatId((cur) => (cur === id ? null : cur)) } catch (err) { console.error('[ai-chat-page] delete failed:', err) setActionError(err instanceof Error ? `Couldn't delete chat: ${err.message}` : "Couldn't delete chat") } }, []) const handleRename = useCallback(async (id: string, title: string) => { setActionError(null) try { const token = await getAuthToken() const headers: Record = { 'Content-Type': 'application/json' } if (token) headers.Authorization = `Bearer ${token}` const res = await fetch(`/api/ai/chats/${id}`, { method: 'PATCH', headers, body: JSON.stringify({ title }), }) if (!res.ok) throw new Error(`rename failed: ${res.status}`) } catch (err) { console.error('[ai-chat-page] rename failed:', err) setActionError(err instanceof Error ? `Couldn't rename chat: ${err.message}` : "Couldn't rename chat") } }, []) if (!isLoaded) { return (
Loading...
) } if (!isSignedIn || !userId) { return ( <>

Sign in to use the assistant

The AI assistant inspects live app data using your permissions.

{showAuth && setShowAuth(false)} />} ) } const chatCardW = chatOpen ? chatWidth : 0 return (

Your app content

Talk to the assistant on the right — it can query your data with server-side tools.

{/* Chat region — no card chrome (matches Miyagi: transparent container, no border, no shadow, no rounded corners). The chat is just content laid out vertically; the only visible separator from the app card is the gap and the resize handle's hover line. */}
{chatOpen && setDragging(true)} dragging={dragging} />} {/* Inner clip layer holds ONLY the chat panel — keeping the history overlay outside this layer means its mount/unmount can't disturb the right-anchored chat panel's layout (which used to cause a visible "slide-left-and-back" sweep on the chat content). */}
setHistoryOpen(true)} /> {createError && ( { void handleNew() }} onDismiss={() => setCreateError(null)} /> )} {actionError && ( setActionError(null)} /> )} } />
{/* History overlay — sibling of app-card / chat-card in the outer flex (NOT inside chat-card). `position: absolute` inherits the outer flex's padding edges, so the panel lines up with the chat region's vertical bounds (top: 8, bottom: 8) instead of stretching to the viewport. Living outside chat-card also keeps its mount/unmount animation from disturbing the chat panel's layout. */} {/* Single page-level toggle — same button in both states, just swaps the icon. Position and chrome are identical when chat is open vs closed, so toggling never moves the button visually. The chat region animates open/closed behind it. */} setChatOpen((o) => !o)} />
) } // ============================================================================ // Error banner — dismissible destructive strip under the header. Retry is // opt-in so a failed delete/rename never offers an unrelated create action. // ============================================================================ function ErrorBanner({ message, onRetry, onDismiss, }: { message: string onRetry?: () => void onDismiss: () => void }) { return (
{message} {onRetry && ( )}
) } // ============================================================================ // Chat header bar — title on the left, action icons on the right. // Mirrors Miyagi's FloatingChatHeader (`title | new chat | history | close`). // ============================================================================ function ChatHeaderBar({ chatId, title, onRename, onNew, onHistory, }: { chatId: string | null title: string onRename: (id: string, title: string) => Promise onNew: () => void onHistory: () => void }) { // pr-12 leaves room for the page-level toggle button that floats at the // top-right corner. Without this, [history] would sit at the same x as // the toggle and they'd visually overlap. return (
) } function HeaderIconButton({ label, onClick, children, }: { label: string onClick: () => void children: React.ReactNode }) { return ( ) } // ============================================================================ // Chat history overlay — slides in from the right inside the chat card. // Mirrors Miyagi's ChatHistoryModal (slide-in side panel with gradient backdrop). // ============================================================================ function ChatHistoryOverlay({ open, chats, activeChatId, onClose, onSelect, onDelete, }: { open: boolean chats: Array<{ recordId: string; data: ChatRow; createdAt?: string; updatedAt?: string }> activeChatId: string | null onClose: () => void onSelect: (id: string) => void onDelete: (id: string) => Promise }) { const closeButtonRef = useRef(null) // Dialog basics: Escape closes; focus moves to the close button on open and // returns to the previously-focused element on close. Without this the // overlay traps keyboard users (the trigger is hidden behind it). useEffect(() => { if (!open) return const prevFocus = document.activeElement as HTMLElement | null closeButtonRef.current?.focus() const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', onKey) return () => { window.removeEventListener('keydown', onKey) prevFocus?.focus?.() } }, [open, onClose]) // Mirrors Miyagi's ChatHistoryModal exactly: a conditional render with no // framer-motion, no AnimatePresence, no transforms. Click outside the // panel closes (the outer div's onClick); the panel itself stops // propagation. Earlier framer-motion versions caused the chat panel to // appear to slide left-and-back because their layout calculations + // backdrop-filter compositing inside the page tree disturbed the // chat-card's layout context. Static positioning has none of that risk. if (!open) return null return (
) } function ChatHistoryRow({ title, timestamp, active, onSelect, onDelete, }: { title?: string timestamp: string active: boolean onSelect: () => void onDelete: () => void }) { const display = (title ?? '').trim() || 'Untitled' return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} className={`group relative flex w-full shrink-0 cursor-pointer flex-col rounded-lg border border-transparent px-3 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-foreground/20 ${ active ? 'border-border/60 bg-muted' : 'hover:border-border hover:bg-muted/60' }`} >
{display}
{timestamp && ( {timestamp} )}
) } // ============================================================================ // Panel toggle — single fixed button at top-right that swaps its icon based // on chatOpen. Position, size, and chrome are identical in both states so // the button NEVER appears to move when toggled — only the chat region // animates open/closed behind it. // ============================================================================ function PanelToggleButton({ open, onClick }: { open: boolean; onClick: () => void }) { const label = open ? 'Close assistant' : 'Open assistant' return ( ) } // ============================================================================ // Editable title bar (no border-b — clean, no extra boxes) // ============================================================================ function ChatTitleBar({ chatId, title, onRename, }: { chatId: string | null title: string onRename: (id: string, title: string) => Promise }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(title) const inputRef = useRef(null) // Sync draft to the latest title — but skip while the user is actively // editing, otherwise an incoming server-side rename (e.g. auto-title) would // clobber their in-progress text under the cursor. useEffect(() => { if (!editing) setDraft(title) }, [title, editing]) useEffect(() => { if (editing) { inputRef.current?.focus() inputRef.current?.select() } }, [editing]) function commit() { if (!editing) return setEditing(false) const next = draft.trim() if (chatId && next && next !== title) void onRename(chatId, next) else setDraft(title) } if (!chatId) { return (
{title}
) } if (editing) { return (
setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit() } if (e.key === 'Escape') { setDraft(title); setEditing(false) } }} className="block w-full bg-transparent text-[13px] font-medium text-foreground outline-none" />
) } return (
) } // ============================================================================ // Resize handle // ============================================================================ function ResizeHandle({ onStart, dragging }: { onStart: () => void; dragging: boolean }) { return (
{ e.preventDefault(); onStart() }} className="group absolute -left-2 top-0 z-20 h-full w-2 cursor-col-resize" >
) }