'use client' import { useEffect, useMemo, useState, useTransition } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { Pin, PinOff, Archive, Search, X, PanelLeftOpen, PanelLeftClose } from 'lucide-react' import { cn } from '@/lib/utils' import { useAgentSheet } from './AgentSheetProvider' import AgentAvatar from './AgentAvatar' interface ConversationRow { id: string intent_id: string context_ref: string | null title: string | null pinned: boolean archived: boolean last_message_at: string | null last_message_preview: string | null created_at: string } interface Props { initialConversations: ConversationRow[] } // Time buckets for date grouping. Computed once per render against now(). // Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from // Mail and iMessage. type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older' const BUCKET_LABELS: Record = { pinned: 'Fästade', today: 'Idag', yesterday: 'Igår', thisWeek: 'Denna vecka', older: 'Äldre', } function bucketFor(c: ConversationRow): DateBucket { if (c.pinned) return 'pinned' const when = c.last_message_at ?? c.created_at if (!when) return 'older' const t = new Date(when) const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000) const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000) if (t >= todayStart) return 'today' if (t >= yesterdayStart) return 'yesterday' if (t >= weekStart) return 'thisWeek' return 'older' } // Compact relative-time label shown to the right of each row. Locale-tuned // to feel native in Swedish without going full date-fns. function relativeTime(iso: string | null | undefined): string { if (!iso) return '' const t = new Date(iso).getTime() const now = Date.now() const diffMin = Math.round((now - t) / 60000) if (diffMin < 1) return 'nu' if (diffMin < 60) return `${diffMin} min` const diffHr = Math.round(diffMin / 60) if (diffHr < 24) return `${diffHr} h` const diffDay = Math.round(diffHr / 24) if (diffDay < 7) return `${diffDay} d` return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' }) } export default function ChatSidebar({ initialConversations }: Props) { const router = useRouter() const pathname = usePathname() const { openAgentSheet, identity } = useAgentSheet() const agentName = identity.displayName?.trim() || null const [conversations, setConversations] = useState(initialConversations) const [query, setQuery] = useState('') const [, startTransition] = useTransition() // Collapsed by default; persisted across reloads so power users keep // their preference. Hidden behind a thin rail when collapsed so the // conversation pane runs nearly edge-to-edge. const [collapsed, setCollapsed] = useState(true) useEffect(() => { const stored = localStorage.getItem('Accounted:chat-sidebar-collapsed') if (stored === 'false') setCollapsed(false) }, []) const toggleCollapsed = () => { setCollapsed(c => { const next = !c try { localStorage.setItem('Accounted:chat-sidebar-collapsed', next ? 'true' : 'false') } catch {} return next }) } const activeId = pathname?.startsWith('/chat/') ? pathname.split('/')[2] : null const isConversationOpen = !!activeId const filtered = useMemo(() => { const q = query.trim().toLowerCase() if (!q) return conversations return conversations.filter((c) => { return ( (c.title ?? '').toLowerCase().includes(q) || (c.last_message_preview ?? '').toLowerCase().includes(q) || (c.context_ref ?? '').toLowerCase().includes(q) || c.intent_id.toLowerCase().includes(q) ) }) }, [conversations, query]) // Group filtered into ordered buckets, preserving the sort order already // applied server-side (pinned first, then last_message_at desc). const grouped = useMemo(() => { const buckets: Record = { pinned: [], today: [], yesterday: [], thisWeek: [], older: [], } for (const c of filtered) buckets[bucketFor(c)].push(c) const order: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older'] return order .map((b) => ({ bucket: b, rows: buckets[b] })) .filter((g) => g.rows.length > 0) }, [filtered]) async function togglePin(id: string, current: boolean) { setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, pinned: !current } : c)), ) await fetch(`/api/agent/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned: !current }), }) } async function archive(id: string) { setConversations((prev) => prev.filter((c) => c.id !== id)) await fetch(`/api/agent/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: true }), }) if (activeId === id) startTransition(() => router.push('/chat')) } // Collapsed rail (desktop only). Mobile keeps the existing behavior where // the sidebar IS the page when no conversation is open, so the rail is // hidden below md. On desktop the rail keeps a thin column with toggle // + new-chat buttons so the conversation pane runs near-edge-to-edge. const railAside = collapsed ? ( ) : null return ( <> {railAside} ) } function intentLabel(intentId: string): string { switch (intentId) { case 'general.help': return 'Fråga din assistent' case 'transaction.categorization': return 'Hjälp med transaktion' case 'invoice.draft': return 'Hjälp med faktura' case 'supplier_invoice.review': return 'Granska leverantörsfaktura' case 'vat.review': return 'Granska moms­deklaration' case 'bokslut.step': return 'Hjälp med bokslut' case 'verifikation.draft': return 'Hjälp med verifikation' case 'kpi.explain': return 'Förklara nyckeltal' default: return intentId } }