import { useSessionStream } from "../stream/useSessionStream"; import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { Transcript } from "./Transcript"; import { NavSessions } from "./NavSessions"; import { SessionMenu } from "./SessionMenu"; import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; import { RenameDialog } from "./RenameDialog"; import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; import { SteerInput, ComposerFooter } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; import { Button } from "../components/ui/button"; import { Toaster } from "../components/ui/sonner"; import { closeSession, listSessions, resumeSession, renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, } from "../api/sessions"; import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { 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 [showArchive, setShowArchive] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>({}); 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 archivedList = sessions.filter((s) => s.archived); const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); function openPanel(id: string) { const s = sessions.find((x) => x.id === id); if (s) setPanelSession(s); } async function doResume(id: string) { await resumeSession(id); setPanelSession(null); setActiveSessionId(id); } async function move(s: SessionSummary, group: string) { await setSessionGroup(s.id, group); refresh(); } async function newGroup(s: SessionSummary) { const name = window.prompt("New group:"); if (name && name.trim()) { await setSessionGroup(s.id, name.trim()); refresh(); } } async function toggleArchive(s: SessionSummary) { await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id)); if (panelSession?.id === s.id) setPanelSession(null); refresh(); } function menuFor(s: SessionSummary) { return ( 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 and no widget is // waiting on the human; once a widget appears, input is back in the user's court. const pendingWidget = useSessionStore((s) => s.pendingWidget); const resetSession = useSessionStore((s) => s.resetSession); const working = Boolean(activeSessionId) && !pendingWidget; useSessionStream(activeSessionId); 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} /> )} {/* Conversation column */}
{activeSessionId && (
)}
{activeSessionId ? ( <> ) : ( )}
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
{/* Right session rail — symmetric to the portal's left sidebar */} {renameTarget && ( { if (!o) setRenameTarget(null); }} onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }} /> )} {deleteTarget && ( { if (!o) setDeleteTarget(null); }} onConfirm={async () => { await deleteSession(deleteTarget.id); if (panelSession?.id === deleteTarget.id) setPanelSession(null); if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); } setDeleteTarget(null); refresh(); }} /> )}
); } /** Brand-tinted spinner shown while the assistant/harness is working. */ function WorkingSpinner({ className }: { className?: string }) { return ( ); } 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.

); }