"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { trpc } from "~/lib/trpc/client.js"; import { ChatMessage, TypingIndicator } from "./ChatMessage.js"; /** Map route prefixes to human-readable page context for the AI */ const ROUTE_CONTEXT: Record = { "/dashboard": "Dashboard — Übersicht mit KPIs, aktive Projekte, Ressourcen-Auslastung", "/timeline": "Timeline — Gantt-artige Ansicht aller Allokationen und Projekte", "/allocations": "Allokationen — Liste aller Zuweisungen von Ressourcen zu Projekten", "/staffing": "Staffing — Projektbesetzung und Kapazitätsplanung", "/resources": "Ressourcen — Liste aller Mitarbeiter mit Details (FTE, LCR, Skills, Chapter)", "/projects": "Projekte — Liste aller Projekte mit Budget, Status, Zeitraum", "/roles": "Rollen — Verwaltung der verfügbaren Rollen", "/estimates": "Estimating — Aufwandsschätzungen für Projekte", "/vacations/my": "Meine Urlaube — Eigene Urlaubsanträge und Saldo", "/vacations": "Urlaubsverwaltung — Alle Urlaubsanträge, Genehmigungen, Team-Kalender", "/analytics/skills": "Skills Analytics — Skill-Verteilung und -Analyse über alle Ressourcen", "/analytics/computation-graph": "Computation Graph — Berechnungsvisualisierung für Budget/Kosten", "/reports/chargeability": "Chargeability Report — Auslastungsanalyse pro Ressource", "/admin/settings": "Admin-Einstellungen — System-Konfiguration, AI-Credentials, SMTP", "/admin/users": "Benutzerverwaltung — Rollen, Berechtigungen, Zugänge", }; function resolvePageContext(pathname: string): string { const exact = ROUTE_CONTEXT[pathname]; if (exact) return exact; const sorted = Object.keys(ROUTE_CONTEXT).sort((a, b) => b.length - a.length); for (const prefix of sorted) { const ctx = ROUTE_CONTEXT[prefix]; if (pathname.startsWith(prefix) && ctx) return ctx; } return pathname; } interface Message { role: "user" | "assistant"; content: string; insights?: AssistantInsight[]; approval?: AssistantApproval; } interface AssistantApproval { id: string; status: "pending" | "approved" | "cancelled"; conversationId: string; toolName: string; summary: string; createdAt: string; expiresAt: string; } interface AssistantInsightMetric { label: string; value: string; tone?: "neutral" | "good" | "warn" | "danger" | "info"; } interface AssistantInsightSection { title: string; metrics: AssistantInsightMetric[]; } interface AssistantInsight { kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays"; title: string; subtitle?: string; metrics: AssistantInsightMetric[]; sections?: AssistantInsightSection[]; } const STORAGE_KEY = "nexus-chat-messages"; const CONVERSATION_ID_KEY = "nexus-chat-conversation-id"; function isAssistantApproval(value: unknown): value is AssistantApproval { if (!value || typeof value !== "object") return false; const approval = value as Partial; return ( typeof approval.id === "string" && (approval.status === "pending" || approval.status === "approved" || approval.status === "cancelled") && typeof approval.conversationId === "string" && typeof approval.toolName === "string" && typeof approval.summary === "string" && typeof approval.createdAt === "string" && typeof approval.expiresAt === "string" ); } /** Load messages from sessionStorage (survives page reloads, clears on tab close). */ function loadPersistedMessages(): Message[] { if (typeof window === "undefined") return []; try { const raw = sessionStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) { return parsed .filter( (item): item is Partial & { role: Message["role"]; content: string } => typeof item === "object" && item !== null && (item.role === "user" || item.role === "assistant") && typeof item.content === "string", ) .map((item) => ({ role: item.role, content: item.content, ...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}), ...(isAssistantApproval(item.approval) ? { approval: item.approval } : {}), })); } } } catch { /* ignore corrupt data */ } return []; } /** Module-level cache — avoids re-parsing sessionStorage on every client-side navigation. */ let cachedMessages: Message[] | null = null; function generateConversationId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `conversation-${Date.now()}`; } function loadConversationId(): string { if (typeof window === "undefined") return generateConversationId(); try { const raw = sessionStorage.getItem(CONVERSATION_ID_KEY)?.trim(); if (raw) return raw; } catch { // ignore storage access errors } return generateConversationId(); } export function ChatPanel({ onClose }: { onClose: () => void }) { const pathname = usePathname(); const router = useRouter(); const utils = trpc.useUtils(); const [messages, setMessages] = useState( () => cachedMessages ?? loadPersistedMessages(), ); const [conversationId, setConversationId] = useState(() => loadConversationId()); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [approvalNotice, setApprovalNotice] = useState(null); const scrollRef = useRef(null); const inputRef = useRef(null); const chatMutation = trpc.assistant.chat.useMutation(); const pendingApprovalsQuery = trpc.assistant.listPendingApprovals.useQuery(undefined, { refetchInterval: 30_000, }); const pendingApprovals = pendingApprovalsQuery.data ?? []; const inlineApprovalIds = new Set( messages .map((message) => message.approval?.id) .filter((approvalId): approvalId is string => typeof approvalId === "string"), ); const visiblePendingApprovals = pendingApprovals.filter( (approval) => !inlineApprovalIds.has(approval.id), ); // Sync to module-level cache + sessionStorage on every change useEffect(() => { cachedMessages = messages; try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch { /* quota exceeded */ } }, [messages]); useEffect(() => { try { sessionStorage.setItem(CONVERSATION_ID_KEY, conversationId); } catch { /* quota exceeded */ } }, [conversationId]); // Auto-scroll to bottom on new messages useEffect(() => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [messages, isLoading]); // Focus input on mount useEffect(() => { inputRef.current?.focus(); }, []); const sendMessage = useCallback( async ( overrideText?: string, targetConversationId?: string, options?: { persistInCurrentChat?: boolean }, ) => { const text = (overrideText ?? input).trim(); if (!text || isLoading) return; const persistInCurrentChat = options?.persistInCurrentChat ?? true; const activeConversationId = targetConversationId ?? conversationId; if (!overrideText) { setInput(""); } setError(null); setApprovalNotice(null); const userMsg: Message = { role: "user", content: text }; const updated = persistInCurrentChat ? [...messages, userMsg] : messages; if (persistInCurrentChat) { setMessages(updated); } setIsLoading(true); try { const reply = await chatMutation.mutateAsync({ messages: (persistInCurrentChat ? updated.slice(-40) : [userMsg]).map((message) => ({ role: message.role, content: message.content, })), ...(pathname ? { pageContext: resolvePageContext(pathname) } : {}), conversationId: activeConversationId, }); const typedReply = reply as { content: string; role: "assistant"; actions?: Array<{ type: string; url?: string; scope?: string[] }>; insights?: AssistantInsight[]; approval?: AssistantApproval; }; if (persistInCurrentChat) { setMessages((prev) => [ ...prev, { role: "assistant", content: typedReply.content, ...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}), ...(isAssistantApproval(typedReply.approval) ? { approval: typedReply.approval } : {}), }, ]); } else { setApprovalNotice(typedReply.content); } // Handle actions from the AI (navigation, data invalidation) const actions = typedReply.actions; if (actions) { for (const action of actions) { if (action.type === "navigate" && action.url) { // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(action.url as any); } else if (action.type === "invalidate" && action.scope) { // Invalidate relevant tRPC queries so the UI refreshes for (const scope of action.scope) { if (scope === "allocation" || scope === "timeline") { void utils.allocation.invalidate(); void utils.timeline.invalidate(); } if (scope === "resource") void utils.resource.invalidate(); if (scope === "project") void utils.project.invalidate(); if (scope === "country") void utils.country.invalidate(); if (scope === "holidayCalendar") void utils.holidayCalendar.invalidate(); if (scope === "vacation") void utils.vacation.invalidate(); } } } } await utils.assistant.listPendingApprovals.invalidate(); } catch (err) { const msg = err instanceof Error ? err.message : "Something went wrong"; setError(msg); } finally { setIsLoading(false); } }, [conversationId, input, isLoading, messages, chatMutation, pathname, router, utils], ); // Track user message history for up-arrow recall const userHistory = useRef([]); const historyIndex = useRef(-1); // Keep userHistory in sync with messages useEffect(() => { userHistory.current = messages.filter((m) => m.role === "user").map((m) => m.content); historyIndex.current = -1; }, [messages]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowUp" && input === "" && userHistory.current.length > 0) { e.preventDefault(); const nextIdx = historyIndex.current < 0 ? userHistory.current.length - 1 : Math.max(0, historyIndex.current - 1); historyIndex.current = nextIdx; setInput(userHistory.current[nextIdx] ?? ""); return; } if (e.key === "ArrowDown" && historyIndex.current >= 0) { e.preventDefault(); const nextIdx = historyIndex.current + 1; if (nextIdx >= userHistory.current.length) { historyIndex.current = -1; setInput(""); } else { historyIndex.current = nextIdx; setInput(userHistory.current[nextIdx] ?? ""); } return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void sendMessage(); } }; const clearChat = () => { setMessages([]); setError(null); setApprovalNotice(null); setConversationId(generateConversationId()); cachedMessages = null; try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } }; return (
{/* Header */}

HartBOT

{messages.length > 0 && ( )}
{/* AI Disclaimer (EGAI 4.3.1.4) */}
AI responses may be inaccurate. Always verify critical information before acting on it.
{/* Messages */}
{(visiblePendingApprovals.length > 0 || approvalNotice) && (
Open approvals
{visiblePendingApprovals.length > 0 && ( {visiblePendingApprovals.length} )}
{approvalNotice && (
{approvalNotice}
)} {visiblePendingApprovals.map((approval) => { const belongsToCurrentConversation = approval.conversationId === conversationId; return (
{approval.summary}
{approval.toolName} Expires {new Date(approval.expiresAt).toLocaleString()}
{belongsToCurrentConversation ? "This chat" : "Other chat"}
); })}
)} {messages.length === 0 && !isLoading && (

Frag mich etwas!

z.B. "Welche Ressourcen gibt es?"

)} {messages.map((msg, i) => (
{msg.role === "assistant" && msg.approval && (
{msg.approval.status === "pending" ? "Approval pending" : msg.approval.status} {msg.approval.toolName}
{msg.approval.summary}
Created: {new Date(msg.approval.createdAt).toLocaleString()} {msg.approval.status === "pending" && ( Expires: {new Date(msg.approval.expiresAt).toLocaleString()} )}
{msg.approval.status === "pending" && (
)}
)}
))} {isLoading && } {error && (
{error}
)}
{/* Input */}