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, Trash2 } from "lucide-react"; import { Button } from "../components/ui/button"; import { Checkbox } from "../components/ui/checkbox"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; import { closeSession, listSessions, resumeSession, getSession, renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime, } 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 [creatingSession, setCreatingSession] = useState(false); 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 [deleteTargets, setDeleteTargets] = useState([]); const [selectedSessionIds, setSelectedSessionIds] = useState>(new Set()); 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"; const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id)); const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length; // A background refresh can remove a session (for example from another browser). // Keep the local selection aligned with the authoritative list. useEffect(() => { const availableIds = new Set(sessions.map((session) => session.id)); setSelectedSessionIds((current) => { const next = new Set([...current].filter((id) => availableIds.has(id))); return next.size === current.size ? current : next; }); }, [sessions]); function setSessionSelected(id: string, selected: boolean) { setSelectedSessionIds((current) => { const next = new Set(current); if (selected) next.add(id); else next.delete(id); return next; }); } function toggleAllSessions(selected: boolean) { setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set()); } 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."); } } async function deleteSessions(targets: SessionSummary[]) { try { const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id))); const deletedIds = new Set( targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id), ); if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null); if (deletedIds.has(activeSessionId ?? "")) { resetSession(); setActiveSessionId(null); } setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id)))); refresh(); if (deletedIds.size !== targets.length) { toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`); } } catch { toast.error("Failed to delete selected sessions."); } } 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={() => { void deleteSessions([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 sessionViewOpen = Boolean(activeSessionId) || creatingSession; const working = sessionViewOpen && !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") return; const ev = (lastSystemEvent as any).event; if (ev === "session_exit") { // Never let a streamed event terminate the managed Pi child. Only the // explicit “Stop & save” action is allowed to call /close. resetSession(); setActiveSessionId(null); } // The final workflow turn ends with the session already finalized on disk: // refetch now instead of waiting for the 10s poll, so the completed state // (and the new-session invite below the transcript) appears immediately. if (ev === "agent_end") refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastSystemEvent]); function startNewSession() { resetSession(); setCreatingSession(false); setActiveSessionId(null); // Best effort only: session creation keeps the authoritative readiness gate. // Composer focus is deliberately independent of this network request. void prewarmRuntime().catch(() => undefined); requestAnimationFrame(() => composerRef.current?.focus()); } function beginSessionCreation() { setCreatingSession(true); } function finishSessionCreation(id: string) { // React batches these updates, preserving the provisional view and timer // while useSessionStream opens the durable session's SSE channel. setActiveSessionId(id); setCreatingSession(false); refresh(); } function failSessionCreation() { setCreatingSession(false); resetSession(); toast.error("Failed to create session. Your question is ready to retry."); } 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. */}
)}
{sessionViewOpen ? ( <> {activeSessionId && } {finalized && !agentActive && (

Session completed and finalized — the SQL and all phase documents are saved.

)} ) : ( )}
{/* 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" /> )} {deleteTargets.length > 0 && ( session.name || session.question || session.id)} onOpenChange={(o) => { if (!o) setDeleteTargets([]); }} onConfirm={() => { const targets = deleteTargets; setDeleteTargets([]); void deleteSessions(targets); }} /> )}
); } 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.

); }