Files
ThothII/frontend/src/shell/AppShell.tsx
T

172 lines
6.2 KiB
TypeScript

import { useSessionStream } from "../stream/useSessionStream";
import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
import { Transcript } from "./Transcript";
import { NavSessions } from "./NavSessions";
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 } from "../api/sessions";
import type { SessionSummary } from "../api/types";
import { useQuery } from "@tanstack/react-query";
import { 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<string | null>(null);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
});
const composerRef = useRef<HTMLInputElement>(null);
// 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 (
<div className="flex h-screen bg-background text-foreground">
{/* Conversation column */}
<div className="flex min-w-0 flex-1 flex-col">
{activeSessionId && (
<div className="shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
<div className="mx-auto max-w-3xl">
<WorkflowBar />
</div>
</div>
)}
<main className="flex-1 overflow-y-auto px-6 py-8">
<div className="mx-auto flex max-w-3xl flex-col gap-6">
{activeSessionId ? (
<>
<Transcript />
<WidgetHost sessionId={activeSessionId} />
</>
) : (
<EmptyState />
)}
</div>
</main>
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
<div className="shrink-0 px-6 pb-6 pt-2">
<div className="mx-auto max-w-3xl">
<div className="rounded-2xl border border-input bg-card shadow-sm transition-colors focus-within:border-primary/40 focus-within:ring-3 focus-within:ring-ring/15">
<div className="px-2.5 py-2">
<SteerInput
sessionId={activeSessionId}
onSessionCreated={setActiveSessionId}
onStop={stopSession}
inputRef={composerRef}
/>
</div>
<ComposerFooter />
</div>
</div>
</div>
</div>
{/* Right session rail — symmetric to the portal's left sidebar */}
<aside className="flex w-[280px] shrink-0 flex-col border-l border-border bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
{working && (
<WorkingSpinner className="absolute left-4 top-[1.35rem]" />
)}
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</h1>
<p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground">
Datamart Builder with
<br />
Human In The Loop
</p>
</div>
<div className="flex flex-col gap-2 px-4 pb-3">
<Button
variant="default"
size="sm"
className="w-full"
onClick={startNewSession}
>
New session
</Button>
</div>
<div className="px-4 pb-1.5 pt-1">
<span className="text-[0.7rem] font-bold uppercase tracking-[0.12em] text-primary">
Sessions
</span>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
<NavSessions
sessions={sessions}
activeSessionId={activeSessionId}
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
menuFor={() => null}
/>
</div>
</aside>
<Toaster />
</div>
);
}
/** Brand-tinted spinner shown while the assistant/harness is working. */
function WorkingSpinner({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
role="status"
aria-label="Assistant is working"
className={["size-4 animate-spin text-primary", className].filter(Boolean).join(" ")}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" opacity="0.2" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
function EmptyState() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<span className="font-heading text-5xl font-semibold tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</span>
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
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.
</p>
</div>
);
}