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

735 lines
29 KiB
TypeScript

import { useSessionStream } from "../stream/useSessionStream";
import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
import { CentralStatus } from "./CentralStatus";
import { ModelActivityPanel } from "./ModelActivityPanel";
import { useActivityPanelResize } from "./useActivityPanelResize";
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, getMe, listSessions, resumeSession, getSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
} from "../api/sessions";
import type { Principal, SessionScope, SessionSummary } from "../api/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties } 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 { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize();
const activityWidthStyle = {
"--activity-panel-width": `${panelWidth}px`,
} as CSSProperties;
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const resumeInvocationRef = useRef(0);
const latestResumeIntentRef = useRef<{ token: number; id: string } | null>(null);
const resumeInFlightRef = useRef(new Map<string, {
latestToken: number;
promise: Promise<void>;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const [sessionScope, setSessionScope] = useState<SessionScope>("mine");
const { data: principal } = useQuery<Principal>({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity });
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
});
const composerRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const [showActivity, setShowActivity] = useState(false);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTargets, setDeleteTargets] = useState<SessionSummary[]>([]);
const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set());
const [stopConfirm, setStopConfirm] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(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;
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.
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]);
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 token = ++resumeInvocationRef.current;
latestResumeIntentRef.current = { token, id };
const inFlight = resumeInFlightRef.current.get(id);
if (inFlight) {
// 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(),
};
operation.promise = runResume(id, operation).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<void> },
) {
try {
const result = await resumeSession(id);
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 };
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 (
latestResumeIntentRef.current?.token === operation.latestToken
&& latestResumeIntentRef.current.id === id
) {
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 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[]) {
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),
);
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();
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
}
} catch {
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 (
<SessionMenu
session={s}
groups={groups}
onResume={() => 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 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 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);
// 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() {
invalidateResumeIntent();
resetSession();
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() {
setAwaitingQuestion(false);
setCreatingSession(true);
}
function finishSessionCreation(id: string) {
// 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() {
setCreatingSession(false);
resetSession();
toast.error("Failed to create session. Your question is ready to retry.");
}
async function stopSession() {
if (!activeSessionId) return;
invalidateResumeIntent();
try {
await closeSession(activeSessionId);
} finally {
resetSession();
selectActiveSession(null);
setAwaitingQuestion(false);
}
}
return (
<div
ref={containerRef}
style={activityWidthStyle}
data-testid="app-shell"
data-activity-layout={showActivity ? "open" : "closed"}
data-activity-resizing={resizing}
className={[
"flex h-screen bg-background text-foreground",
resizing ? "select-none cursor-col-resize" : "",
].join(" ")}
>
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
onClose={() => setPanelSession(null)}
onResume={doResume}
/>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} />}
{showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...separatorProps}
data-testid="activity-resize-handle"
data-dragging={resizing}
className="group absolute inset-y-0 left-1/2 z-20 w-3 -translate-x-1/2 touch-none cursor-col-resize outline-none"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary/60 group-focus-visible:bg-primary group-data-[dragging=true]:bg-primary" />
</div>
</div>
)}
{/* Conversation column */}
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{activeSessionId && (
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
<button
type="button"
onClick={toggleActivity}
aria-expanded={showActivity}
aria-label={showActivity ? "Hide model activity" : "Show model activity"}
title={showActivity ? "Hide model activity" : "Show model activity"}
className="absolute left-4 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{showActivity ? <ArrowLeft className="size-4" /> : <ArrowRight className="size-4" />}
</button>
<div className="mx-auto max-w-4xl">
<WorkflowBar
finalized={finalized}
running={running}
resetKey={activeSessionId}
/>
</div>
</div>
)}
<main className="flex-1 overflow-y-auto px-6 py-8">
<div className="mx-auto flex max-w-4xl flex-col gap-6">
{sessionViewOpen ? (
<>
<CentralStatus working={working} />
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
{finalized && !agentActive && (
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
<p className="text-sm text-muted-foreground">
Session completed and finalized — the SQL and all phase
documents are saved.
</p>
<Button className="mt-3" onClick={startNewSession}>
Start a new question
</Button>
</div>
)}
</>
) : (
<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-4xl">
<div className="rounded-2xl border border-border/80 bg-card shadow-md transition-colors focus-within:border-primary/50 focus-within:ring-3 focus-within:ring-ring/15">
<div className="px-2.5 py-2">
<SteerInput
sessionId={activeSessionId}
onSessionCreating={beginSessionCreation}
onSessionCreated={finishSessionCreation}
onSessionCreateFailed={failSessionCreation}
onStop={() => setStopConfirm(true)}
inputRef={composerRef}
awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"}
/>
</div>
<ComposerFooter />
</div>
</div>
</div>
</div>
{/* Right session rail — symmetric to the portal's left sidebar */}
{!showActivity && (
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
</h1>
<p className="thot-label mt-1.5">
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>
{principal?.isAdmin && (
<div className="px-4 pb-3">
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
<Button
variant={sessionScope === "mine" ? "secondary" : "ghost"}
size="xs"
aria-pressed={sessionScope === "mine"}
onClick={() => setSessionScope("mine")}
>
My sessions
</Button>
<Button
variant={showingAllSessions ? "secondary" : "ghost"}
size="xs"
aria-pressed={showingAllSessions}
onClick={() => setSessionScope("all")}
>
All sessions
</Button>
</div>
{showingAllSessions && (
<p className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
Administrator view: all sessions
</p>
)}
</div>
)}
{/* L1 — rail title */}
<div className="px-4 pb-1.5 pt-1">
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
Sessions
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label="Select all sessions"
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
onClick={() => {
requestDelete(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setActiveOpen((v) => !v)}
aria-expanded={activeOpen}
className="thot-label flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
<span>Active sessions</span>
</button>
{activeOpen && (
<div className="flex flex-col gap-3 pb-2">
{groups.map((g) => (
<div key={g}>
<div className="group/gh flex items-center gap-1">
{/* L2 — group header (sub-level of the section) */}
<button
type="button"
onClick={() => setCollapsedGroups((c) => ({ ...c, [g]: !c[g] }))}
aria-expanded={!collapsedGroups[g]}
className="thot-label flex min-w-0 flex-1 items-center gap-1 px-1 pb-1 text-left hover:text-foreground"
>
<span className="select-none">{collapsedGroups[g] ? "▸" : "▾"}</span>
<span className="truncate">{g}</span>
</button>
<button
type="button"
aria-label={`Rename group ${g}`}
onClick={() => setRenameGroupTarget(g)}
className="rounded-md p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover/gh:opacity-100"
>
<Pencil className="size-3" />
</button>
</div>
{!collapsedGroups[g] && (
<NavSessions
sessions={activeList.filter((s) => s.group === g)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
))}
{/* Ungrouped sessions list directly after the groups — no "No group" label.
When there are no groups at all, still render it so its empty state can
teach first-time users. */}
{(ungroupedActive.length > 0 || groups.length === 0) && (
<NavSessions
sessions={ungroupedActive}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
)}
{/* L2 — section toggle */}
<button
type="button"
onClick={() => setArchiveOpen((v) => !v)}
aria-expanded={archiveOpen}
className="thot-label mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
>
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
<span className="tabular-nums">Archive ({archivedList.length})</span>
</button>
{archiveOpen && (
<NavSessions
sessions={archivedList}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
showOwner={showingAllSessions}
/>
)}
</div>
</aside>
)}
<Toaster />
<StopConfirmDialog
open={stopConfirm}
onOpenChange={setStopConfirm}
onConfirm={stopSession}
/>
{renameTarget && (
<RenameDialog
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={async (name) => {
try {
await renameSession(renameTarget.id, name);
setRenameTarget(null);
refresh();
} catch {
toast.error("Failed to rename session.");
}
}}
/>
)}
{renameGroupTarget && (
<RenameDialog
open
initial={renameGroupTarget}
onOpenChange={(o) => { if (!o) setRenameGroupTarget(null); }}
onSubmit={async (name) => { await renameGroup(renameGroupTarget, name); setRenameGroupTarget(null); }}
title="Rename group"
/>
)}
{deleteTargets.length > 0 && (
<DeleteConfirmDialog
open
labels={deleteTargets.map((session) => session.name || session.question || session.id)}
onOpenChange={(o) => { if (!o) setDeleteTargets([]); }}
onConfirm={() => {
const targets = deleteTargets;
setDeleteTargets([]);
void deleteSessions(targets);
}}
/>
)}
</div>
);
}
function EmptyState() {
return (
<div className="flex min-h-[72vh] 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>
);
}