| import { useEffect, useMemo, useState } from "react"; |
| import { useNavigate } from "react-router"; |
| import { FileText, Menu, PanelRightOpen, X } from "lucide-react"; |
| import { toast } from "sonner"; |
| import KnowledgeManagement from "../KnowledgeManagement"; |
| import { |
| createAnalysisMessage, |
| deleteAnalysis, |
| getAnalysis, |
| getAnalysisMessages, |
| getDatabaseClients, |
| getDocuments, |
| listAnalyses, |
| updateAnalysisDataBind, |
| RESERVED_FAILED_MESSAGE_ID, |
| type Analysis, |
| type AnalysisMessage, |
| type DataBindItem, |
| } from "@/services/orchestrationApi"; |
| import { AgenticApiError, getObservability, streamChat, streamHelp, type AgentStreamEvent } from "@/services/agenticApi"; |
| import { AnalysisHeader } from "./AnalysisHeader"; |
| import { AppNavigation, type AppMenuKey } from "./AppNavigation"; |
| import { ChatInput } from "./ChatInput"; |
| import { HomeDashboard } from "./HomeDashboard"; |
| import { MessageList } from "./MessageList"; |
| import { NewAnalysisDialog } from "./NewAnalysisDialog"; |
| import { ReportSidebar } from "./ReportSidebar"; |
| import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "../ui/resizable"; |
| import { SuggestedQuestionsBar } from "./SuggestedQuestionsBar"; |
| import { clearSession, getCurrentSession } from "./session"; |
| import type { StreamState, UiMessage } from "./types"; |
| import { cx, makeLocalId } from "./utils"; |
|
|
| function toUiMessage(message: AnalysisMessage): UiMessage { |
| const failed = message.status === "failed"; |
| return { |
| id: message.id, |
| role: message.role, |
| content: message.content, |
| created_at: message.created_at, |
| status: failed ? "error" : "complete", |
| statusText: failed ? message.note : undefined, |
| messageId: message.message_id ?? undefined, |
| persistStatus: message.status, |
| persistNote: message.note, |
| }; |
| } |
|
|
|
|
| function sortByUpdatedAtDesc(items: Analysis[]) { |
| return [...items].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); |
| } |
|
|
| function normalizeAssistantContent(content: string) { |
| return content |
| .replace(/([A-Za-z0-9])(?=\*\*)/g, "$1 ") |
| .replace(/(\*\*[^*\n]+\*\*)(?=[A-Za-z0-9])/g, "$1 ") |
| .replace(/\*\*([^*\n]*?)\*\*/g, (_, inner) => `**${inner.trim()}**`) |
| .trim(); |
| } |
|
|
| export function AnalysisShell() { |
| const navigate = useNavigate(); |
| const session = useMemo(() => getCurrentSession(), []); |
| const [activeMenu, setActiveMenu] = useState<AppMenuKey>("home"); |
| const [mobileNavOpen, setMobileNavOpen] = useState(false); |
| const [mobileReportOpen, setMobileReportOpen] = useState(false); |
| const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(false); |
| const [analyses, setAnalyses] = useState<Analysis[]>([]); |
| const [activeAnalysis, setActiveAnalysis] = useState<Analysis | null>(null); |
| const [messages, setMessages] = useState<UiMessage[]>([]); |
| const [loadingAnalyses, setLoadingAnalyses] = useState(true); |
| const [loadingMessages, setLoadingMessages] = useState(false); |
| const [streamState, setStreamState] = useState<StreamState>("idle"); |
| const [scrollTo, setScrollTo] = useState<"bottom" | "last-ai" | null>(null); |
| const [newAnalysisOpen, setNewAnalysisOpen] = useState(false); |
| const [reportCollapsed, setReportCollapsed] = useState(false); |
| const [reportFullscreen, setReportFullscreen] = useState(false); |
| const [reportWidthPercent, setReportWidthPercent] = useState(23); |
| const [staleSources, setStaleSources] = useState<DataBindItem[]>([]); |
|
|
| const selectAnalysis = async (analysis: Analysis) => { |
| setActiveAnalysis(analysis); |
| setLoadingMessages(true); |
| try { |
| const [detail, history] = await Promise.all([getAnalysis(analysis.id), getAnalysisMessages(analysis.id)]); |
| setActiveAnalysis(detail); |
| setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === detail.id ? detail : item)))); |
| setMessages(history.map(toUiMessage)); |
| } catch (err) { |
| toast.error(err instanceof Error ? err.message : "Failed to open analysis"); |
| setMessages([]); |
| } finally { |
| setLoadingMessages(false); |
| } |
| }; |
|
|
| const loadAnalyses = async () => { |
| setLoadingAnalyses(true); |
| try { |
| const result = await listAnalyses({ status: "active", page: 1, limit: 50 }); |
| setAnalyses(sortByUpdatedAtDesc(result.analyses)); |
| if (!activeAnalysis && result.analyses.length > 0) { |
| await selectAnalysis(result.analyses[0]); |
| } |
| } catch (err) { |
| toast.error(err instanceof Error ? err.message : "Failed to load analyses"); |
| } finally { |
| setLoadingAnalyses(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| loadAnalyses(); |
| }, []); |
|
|
| useEffect(() => { |
| const checkStaleSources = async () => { |
| if (!session?.user_id || !activeAnalysis?.data_bind.length) { |
| setStaleSources([]); |
| return; |
| } |
|
|
| try { |
| const [documents, databaseClients] = await Promise.all([getDocuments(session.user_id), getDatabaseClients(session.user_id)]); |
| const documentIds = new Set(documents.map((document) => document.id)); |
| const databaseIds = new Set(databaseClients.map((client) => client.id)); |
| setStaleSources( |
| activeAnalysis.data_bind.filter((source) => { |
| if (source.group_type === "document") return !documentIds.has(source.id); |
| if (source.group_type === "database") return !databaseIds.has(source.id); |
| return false; |
| }) |
| ); |
| } catch { |
| setStaleSources([]); |
| } |
| }; |
|
|
| checkStaleSources(); |
| }, [activeAnalysis?.id, activeAnalysis?.data_bind_version, activeMenu, session?.user_id]); |
|
|
| const fetchTraceability = async (analysisId: string, messageId: string | undefined, uiMessageId: string) => { |
| if (!messageId) return; |
| setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceabilityLoading: true, traceabilityError: null } : msg))); |
|
|
| try { |
| const traceability = await getObservability(analysisId, messageId); |
| setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceability, traceabilityLoading: false } : msg))); |
| } catch (err) { |
| const traceabilityError = |
| err instanceof AgenticApiError && err.status === 404 |
| ? "Traceability not available for this answer." |
| : err instanceof Error |
| ? err.message |
| : "Failed to load traceability"; |
| setMessages((prev) => prev.map((msg) => (msg.id === uiMessageId ? { ...msg, traceabilityLoading: false, traceabilityError } : msg))); |
| } |
| }; |
| useEffect(() => { |
| if (!activeAnalysis?.id || loadingMessages) return; |
|
|
| messages |
| .filter( |
| (message) => |
| message.role === "ai" && |
| message.status === "complete" && |
| Boolean(message.messageId) && |
| message.messageId !== RESERVED_FAILED_MESSAGE_ID && |
| !message.traceability && |
| !message.traceabilityLoading && |
| !message.traceabilityError |
| ) |
| .forEach((message) => { |
| fetchTraceability(activeAnalysis.id, message.messageId, message.id); |
| }); |
| }, [activeAnalysis?.id, loadingMessages, messages]); |
|
|
| const runAgentStream = async (kind: "chat" | "help", prompt?: string) => { |
| if (!session?.user_id || !activeAnalysis || streamState !== "idle") return; |
|
|
| const aiId = makeLocalId(kind === "chat" ? "ai" : "help"); |
| let finalContent = ""; |
| let doneMessageId: string | undefined; |
| let streamError: string | null = null; |
|
|
| setMessages((prev) => [ |
| ...prev, |
| { |
| id: aiId, |
| role: "ai", |
| content: "", |
| created_at: new Date().toISOString(), |
| status: "streaming", |
| statusText: kind === "chat" ? "Reading analysis context" : "Preparing help", |
| }, |
| ]); |
|
|
| setScrollTo("bottom"); |
| setStreamState(kind === "chat" ? "streaming-chat" : "streaming-help"); |
| try { |
| const onEvent = (event: AgentStreamEvent) => { |
| if (event.type === "chunk") { |
| finalContent += event.data; |
| setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, content: finalContent, statusText: undefined } : msg))); |
| } |
| if (event.type === "status") { |
| setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, statusText: event.data } : msg))); |
| } |
| if (event.type === "sources") { |
| setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, sources: event.data } : msg))); |
| } |
| if (event.type === "done") { |
| doneMessageId = typeof event.data.message_id === "string" ? event.data.message_id : undefined; |
| } |
| if (event.type === "error") { |
| streamError = event.data; |
| setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, status: "error", statusText: event.data } : msg))); |
| } |
| }; |
|
|
| if (kind === "chat" && prompt) { |
| await streamChat({ user_id: session.user_id, analysis_id: activeAnalysis.id, message: prompt }, onEvent); |
| } else { |
| await streamHelp({ user_id: session.user_id, analysis_id: activeAnalysis.id }, onEvent); |
| } |
|
|
| if (streamError) return; |
|
|
| const completedContent = normalizeAssistantContent(finalContent); |
| let traceTargetId = aiId; |
| let resolvedMessageId = doneMessageId; |
| setScrollTo("last-ai"); |
| setMessages((prev) => |
| prev.map((msg) => |
| msg.id === aiId |
| ? { ...msg, content: completedContent || msg.content, status: "complete", messageId: doneMessageId, traceabilityLoading: Boolean(doneMessageId) } |
| : msg |
| ) |
| ); |
|
|
| if (completedContent) { |
| const saved = await createAnalysisMessage(activeAnalysis.id, { role: "ai", content: completedContent, message_id: doneMessageId }); |
| traceTargetId = saved.id; |
| const savedUi = toUiMessage(saved); |
| const persistFailed = saved.status === "failed"; |
| resolvedMessageId = persistFailed ? undefined : savedUi.messageId ?? doneMessageId; |
| setMessages((prev) => |
| prev.map((msg) => |
| msg.id === aiId |
| ? { ...savedUi, sources: msg.sources, traceabilityLoading: Boolean(resolvedMessageId) } |
| : msg |
| ) |
| ); |
| if (persistFailed) { |
| toast.error(saved.note || "Failed to save AI response"); |
| return; |
| } |
| } |
|
|
| await fetchTraceability(activeAnalysis.id, resolvedMessageId, traceTargetId); |
| } catch (err) { |
| const message = err instanceof Error ? err.message : "Agent stream failed"; |
| setMessages((prev) => prev.map((msg) => (msg.id === aiId ? { ...msg, status: "error", statusText: message, traceabilityLoading: false } : msg))); |
| } finally { |
| setStreamState("idle"); |
| } |
| }; |
|
|
| const handleSend = async (content: string) => { |
| if (!activeAnalysis || !session?.user_id || streamState !== "idle") return; |
| const localId = makeLocalId("user"); |
| setMessages((prev) => [ |
| ...prev, |
| { id: localId, role: "user", content, created_at: new Date().toISOString(), status: "sending" }, |
| ]); |
|
|
| try { |
| const saved = await createAnalysisMessage(activeAnalysis.id, { role: "user", content }); |
| setMessages((prev) => prev.map((msg) => (msg.id === localId ? toUiMessage(saved) : msg))); |
| } catch (err) { |
| setMessages((prev) => prev.map((msg) => (msg.id === localId ? { ...msg, status: "error", statusText: "Failed to save message" } : msg))); |
| toast.error(err instanceof Error ? err.message : "Failed to save message"); |
| return; |
| } |
|
|
| await runAgentStream("chat", content); |
| }; |
|
|
| const handleHelp = async () => { |
| await runAgentStream("help"); |
| }; |
|
|
| const handleCreated = (analysis: Analysis) => { |
| setAnalyses((prev) => sortByUpdatedAtDesc([analysis, ...prev.filter((item) => item.id !== analysis.id)])); |
| setActiveMenu("analysis-agent"); |
| selectAnalysis(analysis); |
| }; |
|
|
| const handleDelete = async (analysis: Analysis) => { |
| if (!window.confirm(`Delete analysis "${analysis.analysis_title}"?`)) return; |
| try { |
| await deleteAnalysis(analysis.id); |
| const next = analyses.filter((item) => item.id !== analysis.id); |
| setAnalyses(next); |
| if (activeAnalysis?.id === analysis.id) { |
| setActiveAnalysis(null); |
| setMessages([]); |
| setStaleSources([]); |
| if (next[0]) selectAnalysis(next[0]); |
| } |
| } catch (err) { |
| toast.error(err instanceof Error ? err.message : "Failed to delete analysis"); |
| } |
| }; |
|
|
| const handleUpdateDataBind = async (items: DataBindItem[]) => { |
| if (!activeAnalysis) return; |
| const updated = await updateAnalysisDataBind(activeAnalysis.id, activeAnalysis.data_bind_version, items); |
| setActiveAnalysis(updated); |
| setAnalyses((prev) => sortByUpdatedAtDesc(prev.map((item) => (item.id === updated.id ? updated : item)))); |
| setStaleSources([]); |
| }; |
|
|
| const logout = () => { |
| clearSession(); |
| navigate("/login", { replace: true }); |
| }; |
|
|
| const changeMenu = (menu: AppMenuKey) => { |
| setActiveMenu(menu); |
| setMobileNavOpen(false); |
| setMobileReportOpen(false); |
| }; |
|
|
| const activeMenuLabel = |
| activeMenu === "analysis-agent" ? "Analysis Agent" : activeMenu === "knowledge" ? "Knowledge" : "Home"; |
|
|
| const chatPane = ( |
| <main className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50"> |
| <AnalysisHeader analysis={activeAnalysis} staleSources={staleSources} onUpdateDataBind={handleUpdateDataBind} /> |
| <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"> |
| {loadingMessages ? ( |
| <div className="flex h-full items-center justify-center text-sm text-slate-500">Loading messages</div> |
| ) : ( |
| <MessageList |
| messages={messages} |
| scrollTo={scrollTo} |
| onScrollDone={() => setScrollTo(null)} |
| businessQuestions={activeAnalysis?.business_questions ?? []} |
| onSelectQuestion={handleSend} |
| /> |
| )} |
| </div> |
| {messages.length > 0 && ( |
| <SuggestedQuestionsBar questions={activeAnalysis?.business_questions ?? []} onSelect={handleSend} /> |
| )} |
| <ChatInput disabled={!activeAnalysis} streaming={streamState !== "idle"} onSend={handleSend} onHelp={handleHelp} /> |
| </main> |
| ); |
|
|
| const reportPane = reportCollapsed ? ( |
| <div className="flex h-full min-h-0 flex-col border-l border-slate-200 bg-white p-2"> |
| <button |
| type="button" |
| title="Restore report sidebar" |
| aria-label="Restore report sidebar" |
| onClick={() => setReportCollapsed(false)} |
| className="flex h-10 w-10 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-900" |
| > |
| <PanelRightOpen className="h-4 w-4" /> |
| </button> |
| </div> |
| ) : ( |
| <div className="h-full min-h-0 overflow-y-auto border-l border-slate-200 bg-white p-4"> |
| <ReportSidebar |
| analysis={activeAnalysis} |
| userId={session?.user_id} |
| onCollapse={() => setReportCollapsed(true)} |
| fullscreen={reportFullscreen} |
| onToggleFullscreen={() => setReportFullscreen((value) => !value)} |
| /> |
| </div> |
| ); |
|
|
| const renderAnalysisAgent = () => ( |
| <ResizablePanelGroup direction="horizontal" className="h-full min-h-0"> |
| <ResizablePanel |
| id="analysis-chat-panel" |
| order={1} |
| minSize={reportFullscreen ? 0 : 25} |
| className={cx("min-h-0", reportFullscreen && "hidden")} |
| > |
| {chatPane} |
| </ResizablePanel> |
| {/* Report pane + handle are desktop-only; mobile reaches the report via the bottom-sheet rendered further below. */} |
| {!reportFullscreen && <ResizableHandle withHandle className="hidden lg:flex" />} |
| <ResizablePanel |
| id="analysis-report-panel" |
| order={2} |
| minSize={reportFullscreen ? 100 : reportCollapsed ? 4 : 25} |
| maxSize={reportFullscreen ? 100 : reportCollapsed ? 4 : 75} |
| defaultSize={reportFullscreen ? 100 : reportCollapsed ? 4 : reportWidthPercent} |
| onResize={(size) => { |
| if (!reportCollapsed && !reportFullscreen) setReportWidthPercent(size); |
| }} |
| className="hidden min-h-0 lg:block" |
| > |
| {reportPane} |
| </ResizablePanel> |
| </ResizablePanelGroup> |
| ); |
|
|
| return ( |
| <div className="workspace-shell min-h-screen bg-slate-100 text-slate-900"> |
| <header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-slate-200 bg-white px-3 lg:hidden"> |
| <button |
| type="button" |
| title="Open workspace menu" |
| aria-label="Open workspace menu" |
| onClick={() => setMobileNavOpen(true)} |
| className="flex h-10 w-10 items-center justify-center rounded-md text-slate-600 hover:bg-slate-100 hover:text-slate-950" |
| > |
| <Menu className="h-5 w-5" /> |
| </button> |
| <div className="min-w-0 text-center"> |
| <p className="truncate text-sm font-semibold text-slate-950">{activeMenuLabel}</p> |
| <p className="text-xs text-slate-500">Data Eyond</p> |
| </div> |
| {activeMenu === "analysis-agent" ? ( |
| <button |
| type="button" |
| title="Open report panel" |
| aria-label="Open report panel" |
| onClick={() => setMobileReportOpen(true)} |
| className="flex h-10 w-10 items-center justify-center rounded-md text-slate-600 hover:bg-slate-100 hover:text-slate-950" |
| > |
| <FileText className="h-5 w-5" /> |
| </button> |
| ) : ( |
| <div className="h-10 w-10" /> |
| )} |
| </header> |
| |
| {mobileNavOpen && ( |
| <div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-modal="true" aria-label="Workspace navigation"> |
| <button |
| type="button" |
| aria-label="Close workspace menu overlay" |
| onClick={() => setMobileNavOpen(false)} |
| className="absolute inset-0 bg-slate-950/40" |
| /> |
| <div className="relative h-full w-72 max-w-[86vw] bg-white shadow-2xl"> |
| <AppNavigation |
| active={activeMenu} |
| userName={session?.name} |
| analyses={analyses} |
| activeAnalysisId={activeAnalysis?.id} |
| loadingAnalyses={loadingAnalyses} |
| onChange={changeMenu} |
| onLogout={logout} |
| onNewAnalysis={() => { |
| setNewAnalysisOpen(true); |
| setMobileNavOpen(false); |
| }} |
| onSelectAnalysis={(analysis) => { |
| selectAnalysis(analysis); |
| setMobileNavOpen(false); |
| }} |
| onDeleteAnalysis={handleDelete} |
| /> |
| <button |
| type="button" |
| title="Close workspace menu" |
| aria-label="Close workspace menu" |
| onClick={() => setMobileNavOpen(false)} |
| className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-950" |
| > |
| <X className="h-4 w-4" /> |
| </button> |
| </div> |
| </div> |
| )} |
| {mobileReportOpen && activeMenu === "analysis-agent" && ( |
| <div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-modal="true" aria-label="Report panel"> |
| <button |
| type="button" |
| aria-label="Close report panel overlay" |
| onClick={() => setMobileReportOpen(false)} |
| className="absolute inset-0 bg-slate-950/40" |
| /> |
| <div className="absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-xl bg-white p-4 shadow-2xl"> |
| <ReportSidebar analysis={activeAnalysis} userId={session?.user_id} onCollapse={() => setMobileReportOpen(false)} /> |
| </div> |
| </div> |
| )} |
| |
| <div className={cx("lg:grid lg:min-h-0", activeMenu === "analysis-agent" ? "h-[calc(100vh-3.5rem)] min-h-0 overflow-hidden lg:h-screen" : "min-h-screen", desktopNavCollapsed ? "lg:grid-cols-[4.5rem_minmax(0,1fr)]" : "lg:grid-cols-[16rem_minmax(0,1fr)]")}> |
| <div className="hidden min-h-0 lg:block"> |
| <AppNavigation |
| active={activeMenu} |
| userName={session?.name} |
| collapsed={desktopNavCollapsed} |
| analyses={analyses} |
| activeAnalysisId={activeAnalysis?.id} |
| loadingAnalyses={loadingAnalyses} |
| onChange={setActiveMenu} |
| onLogout={logout} |
| onToggleCollapsed={() => setDesktopNavCollapsed((value) => !value)} |
| onNewAnalysis={() => setNewAnalysisOpen(true)} |
| onSelectAnalysis={selectAnalysis} |
| onDeleteAnalysis={handleDelete} |
| /> |
| </div> |
| |
| <div className={cx("min-w-0 min-h-0", activeMenu === "analysis-agent" && "h-full min-h-0 overflow-hidden")}> |
| {activeMenu === "home" && <HomeDashboard hasAnalyses={analyses.length > 0} onNavigate={changeMenu} />} |
| {activeMenu === "knowledge" && <KnowledgeManagement open onClose={() => changeMenu("home")} variant="page" />} |
| {activeMenu === "analysis-agent" && renderAnalysisAgent()} |
| </div> |
| </div> |
| |
| <NewAnalysisDialog open={newAnalysisOpen} onClose={() => setNewAnalysisOpen(false)} onCreated={handleCreated} /> |
| </div> |
| ); |
| } |