import { useSessionStream } from "../stream/useSessionStream"; import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; import { WorkspaceManager } from "./WorkspaceManager"; import { PiManagement } from "./PiManagement"; import { useActivityPanelResize } from "./useActivityPanelResize"; import { useSessionPanelResize } from "./useSessionPanelResize"; 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 { DatabaseManagementPage } from "./DatabaseManagementPage"; 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 { logout as logoutUser } from "../api/auth"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import type { SessionScope, SessionSummary } from "../api/types"; import { useAuthGeneration, useAuthUser } from "../auth/authState"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation"; interface AppShellProps { canLogout: boolean; } type ActiveSurface = "core" | "database-management"; export function AppShell({ canLogout }: AppShellProps) { const authenticatedUser = useAuthUser(); const [panelSession, setPanelSession] = useState(null); const { containerRef, panelWidth, desktopSplit, resizing: activityResizing, separatorProps: activitySeparatorProps, } = useActivityPanelResize(); const { panelWidth: sessionPanelWidth, desktopSplit: sessionDesktopSplit, resizing: sessionResizing, separatorProps: sessionSeparatorProps, } = useSessionPanelResize(containerRef, panelSession !== null); // Publish the app area's horizontal geometry so viewport-fixed dialogs center on // the application area rather than the whole browser window. useEffect(() => { const el = containerRef.current; if (!el) return; const doc = document.documentElement; const publish = () => { const r = el.getBoundingClientRect(); doc.style.setProperty("--app-area-left", `${r.left}px`); doc.style.setProperty("--app-area-width", `${r.width}px`); }; publish(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish); observer?.observe(el); window.addEventListener("resize", publish); return () => { observer?.disconnect(); window.removeEventListener("resize", publish); doc.style.removeProperty("--app-area-left"); doc.style.removeProperty("--app-area-width"); }; }, [containerRef]); const panelWidthsStyle = { "--activity-panel-width": `${panelWidth}px`, "--session-panel-width": `${sessionPanelWidth}px`, } as CSSProperties; const [activeSessionId, setActiveSessionId] = useState(null); const activeSessionIdRef = useRef(null); const activeSessionEpochRef = useRef(0); const newSessionOperationRef = useRef<{ target: string | null; epoch: number } | null>(null); const resumeInvocationRef = useRef(0); const latestResumeIntentRef = useRef<{ token: number; id: string } | null>(null); const resumeInFlightRef = useRef(new Map; guard: AuthOperationGuard; }>()); const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0); const [creatingSession, setCreatingSession] = useState(false); const [awaitingQuestion, setAwaitingQuestion] = useState(false); const [sessionScope, setSessionScope] = useState("mine"); const principal = authenticatedUser; const permissions = authenticatedUser?.permissions ?? []; const canReadAllSessions = permissions.includes("session.read_all"); const canManageWorkspace = permissions.includes("workspace.manage"); const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage"); const canManagePi = permissions.includes("pi.manage"); const authGeneration = useAuthGeneration(); const { data: sessions = [] } = useQuery({ queryKey: ["sessions", sessionScope], queryFn: async () => { const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); if (!guard) throw new StaleAuthOperationError(); const result = await listSessions(sessionScope); if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) throw new StaleAuthOperationError(); return result; }, refetchInterval: 10_000, }); const composerRef = useRef(null); const queryClient = useQueryClient(); const [showActivity, setShowActivity] = useState(false); const [activeSurface, setActiveSurface] = useState("core"); const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false); const [piManagementOpen, setPiManagementOpen] = 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 operationEpochRef = useRef(0); useEffect(() => () => { operationEpochRef.current += 1; }, []); 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 = (operation?: AuthOperationGuard | null) => { if (operation && !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return; void 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; const showingAllSessions = sessionScope === "all"; const isForeignSession = (session: SessionSummary) => { if (!showingAllSessions || !principal) return false; if (!session.author) return true; return session.author !== principal.subject; }; function selectActiveSession(id: string | null) { // Keep async Resume completions synchronized before React commits the state update. if (activeSessionIdRef.current !== id) activeSessionEpochRef.current += 1; activeSessionIdRef.current = id; setActiveSessionId(id); } function invalidateResumeIntent() { resumeInvocationRef.current += 1; latestResumeIntentRef.current = null; } // 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]); useEffect(() => { if (!canReadAllSessions && sessionScope === "all") setSessionScope("mine"); }, [canReadAllSessions, sessionScope]); 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) return; setActiveSurface("core"); // A session with a live Pi runtime opens straight into its live view: doResume // reconnects to the already-active runtime and replays its pending gate, so an // in-progress session never shows an empty screen that reads as "stopped". Cold or // completed sessions keep the read-only documents panel (with its explicit Resume), // so a mere click never spawns a runtime. if (s.active && s.status === "open" && !s.archived && !isForeignSession(s)) { void doResume(id); return; } setPanelSession(s); setShowActivity(false); } function toggleActivity() { setShowActivity((v) => { const next = !v; if (next) setPanelSession(null); return next; }); } async function doResume(id: string) { setActiveSurface("core"); const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); if (!guard) return; const token = ++resumeInvocationRef.current; latestResumeIntentRef.current = { token, id }; const inFlight = resumeInFlightRef.current.get(id); if (inFlight && isAuthOperationCurrent(inFlight.guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) { // Repeated intent for the same target shares one backend lifecycle operation and one // commit path. Updating its token still lets s1→s2→s1 make the final s1 intent authoritative. inFlight.latestToken = token; return inFlight.promise; } const operation = { latestToken: token, promise: Promise.resolve(), guard, }; operation.promise = runResume(id, operation, guard).finally(() => { if (resumeInFlightRef.current.get(id) === operation) { resumeInFlightRef.current.delete(id); } }); resumeInFlightRef.current.set(id, operation); return operation.promise; } async function runResume( id: string, operation: { latestToken: number; promise: Promise }, guard: AuthOperationGuard, ) { try { const result = await resumeSession(id); if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latest = latestResumeIntentRef.current; if (latest?.token !== operation.latestToken || latest.id !== id) return; const reconnectSameSession = activeSessionIdRef.current === id; setPanelSession(null); setAwaitingQuestion(false); // A running/waiting runtime for the currently selected session is already bound to this // store and EventSource. Reopening it would replay state and can lose in-flight delivery. if (result.alreadyActive && reconnectSameSession) return; resetSession(); if (!result.alreadyActive) { recordLifecycle("Resuming session"); setAgentActive(true); } selectActiveSession(id); // A cold runtime starts a fresh SSE id sequence. Recreate a same-id source only after // Resume succeeds, and explicitly discard the old runtime's cursor. if (!result.alreadyActive && reconnectSameSession) { setStreamCursorResetEpoch((value) => value + 1); } // Single-flight covers only the backend Resume and its local binding commit. A slow // manifest read must not prevent a later explicit intent from starting a new Resume. if (resumeInFlightRef.current.get(id) === operation) { resumeInFlightRef.current.delete(id); } // Paint the persisted re-entry phase while the replacement stream starts replaying. // The manifest's `phase` is the 1-based current phase (1..8). try { const m = (await getSession(id)) as { phase?: number }; if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; const latestAfterManifest = latestResumeIntentRef.current; if ( latestAfterManifest?.token !== operation.latestToken || latestAfterManifest.id !== id || activeSessionIdRef.current !== id ) return; 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 { if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) && latestResumeIntentRef.current?.token === operation.latestToken && latestResumeIntentRef.current.id === id ) { toast.error("Failed to resume session."); } } } async function move(s: SessionSummary, group: string) { const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); if (!guard) return; try { await setSessionGroup(s.id, group); if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; refresh(guard); } catch { if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session."); } } async function newGroup(s: SessionSummary) { const name = window.prompt("New group:"); if (name && name.trim()) { const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); if (!guard) return; try { await setSessionGroup(s.id, name.trim()); if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; refresh(guard); } catch { if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group."); } } } async function renameGroup(oldName: string, newName: string, suppliedGuard?: AuthOperationGuard | null) { const trimmed = newName.trim(); if (!trimmed || trimmed === oldName) return false; const guard = suppliedGuard ?? captureAuthOperation({ disposalEpoch: operationEpochRef.current }); if (!guard) return false; try { for (const s of sessions.filter((x) => x.group === oldName)) { await setSessionGroup(s.id, trimmed); if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return false; } refresh(guard); return true; } catch { if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group."); return false; } } async function renameSessionFromDialog(id: string, name: string) { const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); if (!guard) return; try { await renameSession(id, name); if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) return; setRenameTarget(null); refresh(guard); } catch { if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) { toast.error("Failed to rename session."); } } } async function toggleArchive(s: SessionSummary) { const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current }); if (!guard) return; try { await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id)); if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return; if (panelSession?.id === s.id) setPanelSession(null); refresh(guard); } catch { if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) { toast.error(s.archived ? "Failed to restore session." : "Failed to archive session."); } } } function requestArchiveToggle(session: SessionSummary) { if (!session.archived && isForeignSession(session)) { const label = session.author ? `${session.author}'s session` : "this session"; if (!window.confirm(`Archive ${label}?`)) return; } void toggleArchive(session); } async function deleteSessions(targets: SessionSummary[]) { const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); if (!guard) return; try { const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id))); if (!isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) return; const deletedIds = new Set( targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id), ); const deletedActiveSession = deletedIds.has(activeSessionIdRef.current ?? ""); const deletedResumeTarget = deletedIds.has(latestResumeIntentRef.current?.id ?? ""); if (deletedResumeTarget) invalidateResumeIntent(); if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null); if (deletedActiveSession) { resetSession(); selectActiveSession(null); } setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id)))); refresh(guard); if (deletedIds.size !== targets.length) { toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`); } } catch { if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions."); } } function requestDelete(targets: SessionSummary[]) { if (targets.some(isForeignSession) || allSessionsSelected) { setDeleteTargets(targets); return; } void deleteSessions(targets); } function menuFor(s: SessionSummary) { return ( doResume(s.id)} onView={() => setPanelSession(s)} onRename={() => setRenameTarget(s)} onMove={(g) => move(s, g)} onNewGroup={() => newGroup(s)} onArchiveToggle={() => requestArchiveToggle(s)} onDelete={() => requestDelete([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 working state on forever. const pendingWidget = useSessionStore((s) => s.pendingWidget); const sessionToasts = useSessionStore((s) => s.toasts); const resetSession = useSessionStore((s) => s.resetSession); const recordLifecycle = useSessionStore((s) => s.recordLifecycle); 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 deliveredToastCountRef = useRef(0); useEffect(() => { if (sessionToasts.length < deliveredToastCountRef.current) { deliveredToastCountRef.current = 0; } for (const notification of sessionToasts.slice(deliveredToastCountRef.current)) { if (notification.level === "error") toast.error(notification.text); else if (notification.level === "success") toast.success(notification.text); else if (notification.level === "warning") toast.warning(notification.text); else toast.info(notification.text); } deliveredToastCountRef.current = sessionToasts.length; }, [sessionToasts]); const sessionViewOpen = Boolean(activeSessionId) || creatingSession; const working = sessionViewOpen && !pendingWidget && agentActive; // The workflow bar runs 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, 0, streamCursorResetEpoch, Boolean(authenticatedUser), authGeneration, ); // 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. invalidateResumeIntent(); resetSession(); selectActiveSession(null); setAwaitingQuestion(false); } // 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() { setActiveSurface("core"); invalidateResumeIntent(); newSessionOperationRef.current = null; resetSession(); // Starting a new question closes any open session detail panel: the reader is // moving away from that session, so its left-hand box must not linger. setPanelSession(null); setAwaitingQuestion(true); setCreatingSession(false); selectActiveSession(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() { newSessionOperationRef.current = { target: activeSessionIdRef.current, epoch: activeSessionEpochRef.current, }; setAwaitingQuestion(false); setCreatingSession(true); } function finishSessionCreation(id: string) { const operation = newSessionOperationRef.current; newSessionOperationRef.current = null; if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return; // React batches these updates, preserving the provisional session view // while useSessionStream opens the durable session's SSE channel. selectActiveSession(id); setCreatingSession(false); setAwaitingQuestion(false); refresh(); } function failSessionCreation(message?: string) { const operation = newSessionOperationRef.current; newSessionOperationRef.current = null; if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return; setCreatingSession(false); resetSession(); toast.error(message ?? "Failed to create session. Your question is ready to retry."); } async function stopSession() { const id = activeSessionIdRef.current; if (!id) return; const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current }); if (!guard) return; invalidateResumeIntent(); try { await closeSession(id); } finally { if (!isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current }) || activeSessionIdRef.current !== id) return; resetSession(); selectActiveSession(null); setAwaitingQuestion(false); } } async function signOut() { await logoutUser(); } return (
{panelSession && ( setPanelSession(null)} onResume={doResume} desktopSplit={sessionDesktopSplit} hidden={activeSurface !== "core"} /> )} {activeSurface === "core" && panelSession && sessionDesktopSplit && (
)} {showActivity && setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} hidden={activeSurface !== "core"} />} {activeSurface === "core" && showActivity && desktopSplit && (
)} {/* Conversation column */}
{activeSurface === "database-management" && }
{activeSessionId && (
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
)}
{sessionViewOpen ? ( <> {activeSession?.question && (

Domanda

{activeSession.question}

)} {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} awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"} working={running} />
{/* Right session rail */} {!showActivity && ( )} setWorkspaceManagerOpen(false)} canManageWorkspace={canManageWorkspace} canManageSecrets={canManageWorkspaceSecrets} /> {canManagePi && setPiManagementOpen(false)} />} {renameTarget && ( { if (!o) setRenameTarget(null); }} onSubmit={(name) => renameSessionFromDialog(renameTarget.id, name)} /> )} {renameGroupTarget && ( { if (!o) setRenameGroupTarget(null); }} onSubmit={async (name) => { const target = renameGroupTarget; const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current }); if (await renameGroup(target, name, guard) && guard && isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) { 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 database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.

); }