/** * DO-backed AI chat surface. * * Pass `chatId={null}` to create a chat on first send. The panel keeps the * created id itself; `onChatCreated` merely notifies, and a parent-passed * `chatId` — including a change back to null for "new chat" — always wins. */ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, } from 'react' import { AlertCircle, ArrowUp, Check, ChevronDown, Square, } from 'lucide-react' import { listDeepSpaceAgentModels, useQuery } from 'deepspace' import { EmptyState, MessageTurn, ThinkingIndicator } from './ChatPanel.messages' import { useStreamingChat } from './ChatPanel.stream' export type ModelOption = { id: string label: string provider: string } type AiMessageData = { chatId: string userId: string role: 'user' | 'assistant' | 'system' content: string parts?: unknown[] } type RenderMessage = { id: string role: AiMessageData['role'] content: string parts?: unknown[] } export type ChatPanelProps = { /** Active chat. `null` creates one on first send and the panel keeps it — a never-passed `chatId` means one chat per mount; remount with `key` for a new one. */ chatId: string | null /** Current user id; scopes the messages query as defense in depth. */ userId: string /** Notified with the id when the panel creates a chat on first send. */ onChatCreated?: (chatId: string) => void /** Models shown in the picker. */ models?: ModelOption[] /** Clickable prompts shown when the conversation is empty. */ emptyStatePrompts?: string[] /** Applied to the outer container. */ className?: string /** Optional content rendered above the messages. */ header?: ReactNode /** Tighter spacing for narrow containers. */ compact?: boolean /** Suspends send while a parent-owned create is in flight. */ disabled?: boolean } const MODEL_STORAGE_KEY = 'deepspace-ai-model' const DEFAULT_PROMPTS = [ 'What can you help with?', 'Summarize recent activity', 'List my collections', ] const DEFAULT_MODELS: ModelOption[] = listDeepSpaceAgentModels('application').map((model) => ({ id: model.id, label: model.label, provider: model.providerLabel, })) export function ChatPanel({ chatId, userId, onChatCreated, models: modelOptions, emptyStatePrompts = DEFAULT_PROMPTS, className, header, compact = false, disabled = false, }: ChatPanelProps) { const models = modelOptions ?? DEFAULT_MODELS const scrollRef = useRef(null) const inputRef = useRef(null) const stickToBottomRef = useRef(true) const [input, setInput] = useState('') const [modelId, setModelId] = useState(() => initialModelId(models), ) // A custom model list may change at runtime. Never keep sending an id the // picker no longer offers (the worker correctly rejects unknown ids). useEffect(() => { if (modelId && models.some((model) => model.id === modelId)) return setModelId(models[0]?.id) }, [modelId, models]) const groupedModels = useMemo(() => groupModelsByProvider(models), [models]) const selectedModel = models.find((model) => model.id === modelId) // Uncontrolled fallback: with `chatId={null}` the panel keeps the chat it // auto-creates on first send, so the overlay, the messages query, and later // sends all target that chat. Any prop change — including back to null, // which means "new chat" — takes precedence and clears it. const [ownChatId, setOwnChatId] = useState(null) const [seenChatId, setSeenChatId] = useState(chatId) if (seenChatId !== chatId) { setSeenChatId(chatId) setOwnChatId(null) } const activeChatId = chatId ?? ownChatId const handleChatCreated = useCallback( (id: string) => { setOwnChatId(id) onChatCreated?.(id) }, [onChatCreated], ) const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat({ chatId: activeChatId, modelId, onChatCreated: handleChatCreated, }) const queryWhere = useMemo( () => ({ chatId: activeChatId ?? '__none__', userId }), [activeChatId, userId], ) const { records } = useQuery('ai-messages', { where: queryWhere, orderBy: 'createdAt', orderDir: 'asc', }) const persisted = useMemo( () => records.map((record) => ({ id: record.recordId, role: record.data.role, content: record.data.content ?? '', parts: record.data.parts, })), [records], ) const persistedIds = useMemo( () => new Set(persisted.map((message) => message.id)), [persisted], ) const messages = useMemo(() => { const overlay = inFlight.filter((message) => { if (message.forChatId !== activeChatId) return false if (persistedIds.has(message.id)) return false return !message.serverId || !persistedIds.has(message.serverId) }) return [...persisted, ...overlay] }, [activeChatId, inFlight, persisted, persistedIds]) useEffect(() => { const element = scrollRef.current if (!element) return const trackPosition = () => { const bottomGap = element.scrollHeight - element.scrollTop - element.clientHeight stickToBottomRef.current = bottomGap < 80 } element.addEventListener('scroll', trackPosition, { passive: true }) return () => element.removeEventListener('scroll', trackPosition) }, []) useEffect(() => { const element = scrollRef.current if (!element || !stickToBottomRef.current) return element.scrollTo({ top: element.scrollHeight, behavior: isLoading ? 'auto' : 'smooth', }) }, [isLoading, messages]) useEffect(() => { const element = inputRef.current if (!element) return if (!input) { element.style.height = '' return } const frame = requestAnimationFrame(() => { element.style.height = 'auto' element.style.height = `${Math.min(element.scrollHeight, 200)}px` }) return () => cancelAnimationFrame(frame) }, [input]) const canSend = input.trim().length > 0 && !isLoading && !disabled function submit() { if (!canSend) return const content = input.trim() setInput('') void send(content) } function handleKeyDown(event: KeyboardEvent) { // Enter commits an active IME composition; it must not also send it. if (event.nativeEvent.isComposing || event.keyCode === 229) return if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() submit() } } function selectPrompt(prompt: string) { setInput(prompt) inputRef.current?.focus() } function selectModel(id: string) { setModelId(id) try { window.localStorage.setItem(MODEL_STORAGE_KEY, id) } catch { // Storage can be unavailable in privacy modes; selection still works. } } const lastMessage = messages[messages.length - 1] const streamingAssistantId = isLoading && lastMessage?.role === 'assistant' ? lastMessage.id : null const waitingForAssistant = isLoading && lastMessage?.role === 'user' const horizontalPadding = compact ? 'px-4' : 'px-6' const textSize = compact ? 'text-[13px]' : 'text-[14px]' const turnGap = compact ? 'gap-6' : 'gap-8' return (
{header &&
{header}
}
{messages.length === 0 ? ( ) : (
{messages.map((message) => ( ))} {waitingForAssistant && }
)}
{error && (
)}
{ event.preventDefault() submit() }} className="mx-auto w-full max-w-[44rem]" >