import { useSessionStream } from "../stream/useSessionStream"; import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; import { NavSessions } from "./NavSessions"; import { SessionMenu } from "./SessionMenu"; import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; import { RenameDialog } from "./RenameDialog"; import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; import { StopConfirmDialog } from "./StopConfirmDialog"; import { SteerInput, ComposerFooter } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; import { Pencil, ArrowLeft, ArrowRight } from "lucide-react"; import { Button } from "../components/ui/button"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; import { closeSession, listSessions, resumeSession, getSession, renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, } from "../api/sessions"; import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; /** * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar * + topbar) once embedded, so this shell intentionally has no left rail and no * top header of its own. The session rail sits on the RIGHT, mirroring the * portal's left sidebar, and the workflow phases ride a slim strip above the chat. */ export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); const { data: sessions = [] } = useQuery({ queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, }); const composerRef = useRef(null); const queryClient = useQueryClient(); const [panelSession, setPanelSession] = useState(null); const [showActivity, setShowActivity] = useState(false); const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [stopConfirm, setStopConfirm] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>({}); const [renameGroupTarget, setRenameGroupTarget] = useState(null); const groups = useMemo( () => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(), [sessions], ); const activeList = sessions.filter((s) => !s.archived); const ungroupedActive = activeList.filter((s) => !s.group); const archivedList = sessions.filter((s) => s.archived); const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; const finalized = activeSession?.status === "finalized"; function openPanel(id: string) { const s = sessions.find((x) => x.id === id); if (s) { setPanelSession(s); setShowActivity(false); } } function toggleActivity() { setShowActivity((v) => { const next = !v; if (next) setPanelSession(null); return next; }); } async function doResume(id: string) { const s = sessions.find((x) => x.id === id) ?? null; // Optimistic switch: change to the session view IMMEDIATELY so the click feels // instant (the resume POST spawns a Pi process and can take seconds). The // working spinner shows straight away; the backend calls run after. setPanelSession(null); resetSession(); // Optimistic: the resume POST is about to hand the ball to the harness. setAgentActive(true); setActiveSessionId(id); try { await resumeSession(id); // Optimistic phase paint: colour the re-entry phase before the first gate. // The manifest's `phase` is the 1-based current phase (1..8). try { const m = (await getSession(id)) as { phase?: number }; if (typeof m.phase === "number" && m.phase >= 1 && m.phase <= 8) { setPhase(`F${m.phase}`); } } catch { /* non-fatal: the first gate will set the phase */ } } catch { // Revert the optimistic switch and restore the panel. resetSession(); setActiveSessionId(null); if (s) setPanelSession(s); toast.error("Failed to resume session."); } } async function move(s: SessionSummary, group: string) { try { await setSessionGroup(s.id, group); refresh(); } catch { toast.error("Failed to move session."); } } async function newGroup(s: SessionSummary) { const name = window.prompt("New group:"); if (name && name.trim()) { try { await setSessionGroup(s.id, name.trim()); refresh(); } catch { toast.error("Failed to update group."); } } } async function renameGroup(oldName: string, newName: string) { const trimmed = newName.trim(); if (!trimmed || trimmed === oldName) return; try { for (const s of sessions.filter((x) => x.group === oldName)) { await setSessionGroup(s.id, trimmed); } refresh(); } catch { toast.error("Failed to rename group."); } } async function toggleArchive(s: SessionSummary) { try { await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id)); if (panelSession?.id === s.id) setPanelSession(null); refresh(); } catch { toast.error(s.archived ? "Failed to restore session." : "Failed to archive session."); } } function menuFor(s: SessionSummary) { return ( doResume(s.id)} onView={() => setPanelSession(s)} onRename={() => setRenameTarget(s)} onMove={(g) => move(s, g)} onNewGroup={() => newGroup(s)} onArchiveToggle={() => toggleArchive(s)} onDelete={() => setDeleteTarget(s)} /> ); } // The harness "holds the ball" whenever a session is live, no widget is waiting on // the human, AND the Pi turn is still in flight (agentActive). Without the last // condition the final workflow step — which ends with no follow-up gate — would // leave the spinner on forever. const pendingWidget = useSessionStore((s) => s.pendingWidget); const resetSession = useSessionStore((s) => s.resetSession); const setPhase = useSessionStore((s) => s.setPhase); const setAgentActive = useSessionStore((s) => s.setAgentActive); const lastSystemEvent = useSessionStore((s) => s.lastSystemEvent); const agentActive = useSessionStore((s) => s.agentActive); const working = Boolean(activeSessionId) && !pendingWidget && agentActive; // Processing time counts only while the harness works, not while a finalized // session sits idle or a gate awaits the reviewer (pendingWidget). const running = working && !finalized; useSessionStream(activeSessionId); // A backend "session_exit" system event (e.g. the replay server emitting it // when the reviewer picks "Esci") asks us to leave the live session view and // return to the landing state. We deliberately do NOT also POST /close here — // stopSession already does, and this effect must stay side-effect-free on the // backend if the session is already inactive. useEffect(() => { if (lastSystemEvent?.type === "system_event" && (lastSystemEvent as any).event === "session_exit") { stopSession(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastSystemEvent]); function startNewSession() { resetSession(); setActiveSessionId(null); requestAnimationFrame(() => composerRef.current?.focus()); } async function stopSession() { if (!activeSessionId) return; try { await closeSession(activeSessionId); } finally { resetSession(); setActiveSessionId(null); } } return (
{panelSession && ( setPanelSession(null)} onResume={doResume} /> )} {showActivity && setShowActivity(false)} />} {/* Conversation column */}
{activeSessionId && (
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
)}
{activeSessionId ? ( <> ) : ( )}
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
setStopConfirm(true)} inputRef={composerRef} />
{/* Right session rail — symmetric to the portal's left sidebar */} {renameTarget && ( { if (!o) setRenameTarget(null); }} onSubmit={async (name) => { try { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); } catch { toast.error("Failed to rename session."); } }} /> )} {renameGroupTarget && ( { if (!o) setRenameGroupTarget(null); }} onSubmit={async (name) => { await renameGroup(renameGroupTarget, name); setRenameGroupTarget(null); }} title="Rename group" /> )} {deleteTarget && ( { if (!o) setDeleteTarget(null); }} onConfirm={async () => { try { await deleteSession(deleteTarget.id); if (panelSession?.id === deleteTarget.id) setPanelSession(null); if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); } setDeleteTarget(null); refresh(); } catch { toast.error("Failed to delete session."); } }} /> )}
); } function EmptyState() { return (
ThothII

Build datamarts from your data warehouse through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.

); }