'use client' import { useEffect, useState } from 'react' import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react' import AgentChat, { normalizeStoredMessages, type ChatMessage } from './AgentChat' import AgentAvatar from './AgentAvatar' import AgentSessionList from './AgentSessionList' import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' import { useCompanyOptional } from '@/contexts/CompanyContext' import { cn } from '@/lib/utils' // Undimmed non-modal side sheet: sits above the page on a hairline border + // shadow, but the page underneath stays fully interactive. Plan §3b. // // The sheet is a thin wrapper around AgentChat: it owns the title bar, close // button, and "expand to /chat/[id]" affordance. All message rendering and // streaming live in AgentChat so the full-page chat view can reuse them. interface Props { intentId: string intentArgs?: Record contextRef?: string seedUserMessage?: string // Hidden (display:none) but still mounted so the conversation survives. The // provider keeps rendering this component; we just visually remove it. collapsed: boolean onCollapse: () => void onRestart: () => void onClose: () => void } interface LoadedConversation { id: string intentId: string contextRef: string | null title: string | null messages: ChatMessage[] } export default function AgentSheet({ intentId, intentArgs, contextRef, seedUserMessage, collapsed, onCollapse, onRestart, onClose, }: Props) { // Live conversation id from the active AgentChat (fresh sessions report it via // onConversationIdChange; resumed ones we set directly on select). const [conversationId, setConversationId] = useState(null) // 'chat' shows the conversation; 'list' shows the session picker. const [view, setView] = useState<'chat' | 'list'>('chat') // A past conversation the user picked from the list, hydrated for resume. When // set, it replaces the intent-driven fresh chat. const [loaded, setLoaded] = useState(null) const [loadingConversation, setLoadingConversation] = useState(false) const [loadError, setLoadError] = useState(null) // Enlarge the panel IN PLACE (no navigation): the user stays on the current // page (e.g. /bookkeeping) with a wider reading/verifying surface. const [expanded, setExpanded] = useState(false) const { identity } = useAgentSheet() const companyCtx = useCompanyOptional() const isSandbox = companyCtx?.isSandbox ?? false const agentName = identity.displayName?.trim() || null const sheetTitle = intentToTitle(intentId, agentName) const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle const activeConversationId = loaded?.id ?? conversationId // Esc: back out of the session list first, otherwise close. Never while // collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (collapsed || e.key !== 'Escape') return if (view === 'list') setView('chat') else onClose() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onClose, collapsed, view]) // Move focus off the sheet before hiding it, so it never sits on a // display:none node (accessibility). const handleCollapse = () => { if (typeof document !== 'undefined') { ;(document.activeElement as HTMLElement | null)?.blur() } onCollapse() } // Resume a past conversation inline: fetch its messages, hydrate, and swap the // sheet back to the chat view. Picking the one already open just closes the // list (keeps its live in-memory state instead of re-hydrating it). async function handleSelectConversation(id: string) { if (id === activeConversationId) { setView('chat') return } setView('chat') setLoaded(null) setLoadingConversation(true) setLoadError(null) try { const res = await fetch(`/api/agent/conversations/${id}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const json = (await res.json()) as { data?: { conversation: { id: string intent_id: string context_ref: string | null title: string | null } messages: { role: string; content: unknown; hidden?: boolean | null }[] } } const data = json.data if (!data) throw new Error('missing data') setLoaded({ id: data.conversation.id, intentId: data.conversation.intent_id, contextRef: data.conversation.context_ref, title: data.conversation.title, messages: normalizeStoredMessages(data.messages), }) setConversationId(data.conversation.id) } catch { setLoadError('Kunde inte öppna konversationen.') } finally { setLoadingConversation(false) } } return (
{view === 'list' ? (

Konversationer

) : (
{!isSandbox && ( )}

{displayTitle}

{/* Grow/shrink the panel in place: NEVER navigates away, so the user stays on the current page. Hidden on mobile where the sheet is already full-width (the toggle would be a no-op). */} {!isSandbox && ( )} {/* Labeled (not icon-only) so it isn't mistaken for close/minimize, and gated on an existing conversation so there's nothing to mis-click on a fresh, empty chat. */} {activeConversationId && !isSandbox && ( )}
)} {isSandbox ? ( ) : view === 'list' ? ( ) : loadingConversation ? (
Öppnar konversation…
) : loadError ? (

{loadError}

) : loaded ? ( setConversationId(id)} /> ) : ( setConversationId(id)} /> )}
) } function intentToTitle(intentId: string, agentName: string | null): string { switch (intentId) { case 'general.help': return agentName ? `Fråga ${agentName}` : 'Fråga din assistent' case 'transaction.categorization': return 'Hjälp med transaktion' case 'verifikation.draft': return 'Hjälp med verifikation' case 'invoice.draft': return 'Hjälp med faktura' case 'supplier_invoice.review': return 'Granska leverantörsfaktura' default: return agentName ? `Fråga ${agentName}` : 'Fråga din assistent' } }