Files
ThothII/frontend/src/shell/AppShell.tsx
T
marcopan 610ae8c85a fix(auth): make local verification portable
Keep upstream identity visible while limiting logout to local auth. Inject the restore privilege gate so the deterministic core tests do not depend on the host OS, and confine descriptor-backed projection tests to Linux. Accept the real remaining Pi timeout budget instead of an exact millisecond.
2026-08-25 10:50:30 +02:00

999 lines
42 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 { 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 { 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,
checkDwhHealth,
} 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;
}
export function AppShell({ canLogout }: AppShellProps) {
const authenticatedUser = useAuthUser();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(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<string | null>(null);
const activeSessionIdRef = useRef<string | null>(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<string, {
latestToken: number;
promise: Promise<void>;
guard: AuthOperationGuard;
}>());
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const [sessionScope, setSessionScope] = useState<SessionScope>("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<SessionSummary[]>({
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<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
const [piManagementOpen, setPiManagementOpen] = 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 [dwhDown, setDwhDown] = useState(false);
const [dwhChecking, setDwhChecking] = useState(true);
const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
useEffect(() => {
let cancelled = false;
const operation = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
if (!operation) return () => { cancelled = true; };
setDwhChecking(true);
checkDwhHealth().then((r) => {
if (cancelled || !isAuthOperationCurrent(operation, { disposalEpoch: operationEpochRef.current })) return;
setDwhDown(!r.ok);
setDwhChecking(false);
});
return () => { cancelled = true; };
}, [dwhCheckEpoch]);
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;
// 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) {
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<void> },
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 (
<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 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() {
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 (
<div
ref={containerRef}
style={panelWidthsStyle}
data-testid="app-shell"
data-activity-layout={showActivity ? "open" : "closed"}
data-activity-resizing={activityResizing}
data-session-resizing={sessionResizing}
className={[
"flex h-screen bg-background text-foreground",
activityResizing || sessionResizing ? "select-none cursor-col-resize" : "",
].join(" ")}
>
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
onClose={() => setPanelSession(null)}
onResume={doResume}
desktopSplit={sessionDesktopSplit}
/>
)}
{panelSession && sessionDesktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...sessionSeparatorProps}
data-testid="session-resize-handle"
data-dragging={sessionResizing}
className="group absolute inset-y-0 left-1/2 z-30 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>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} />}
{showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
{...activitySeparatorProps}
data-testid="activity-resize-handle"
data-dragging={activityResizing}
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 ? (
<>
{activeSession?.question && (
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<p className="thot-label mb-0.5 text-muted-foreground">Domanda</p>
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
{activeSession.question}
</h2>
</header>
)}
<CentralStatus working={working} />
{activeSessionId && <WidgetHost key={`widget:${activeSessionId}:${activeSessionEpochRef.current}`} 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
key={`steer:${activeSessionId ?? "new"}:${activeSessionEpochRef.current}`}
sessionId={activeSessionId}
onSessionCreating={beginSessionCreation}
onSessionCreated={finishSessionCreation}
onSessionCreateFailed={failSessionCreation}
onStop={() => setStopConfirm(true)}
inputRef={composerRef}
awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"}
working={running}
/>
</div>
<ComposerFooter />
</div>
</div>
</div>
</div>
{/* Right session rail */}
{!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>
{authenticatedUser && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject}
</span>
{canLogout && (
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
Log out
</Button>
)}
</div>
)}
</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>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setWorkspaceManagerOpen(true)}
>
Workspace management
</Button>
{canManagePi && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setPiManagementOpen(true)}
>
Pi management
</Button>
)}
</div>
{canReadAllSessions && (
<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>
)}
<WorkspaceManager
open={workspaceManagerOpen}
onClose={() => setWorkspaceManagerOpen(false)}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && <PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />}
<Toaster />
<StopConfirmDialog
open={stopConfirm}
onOpenChange={setStopConfirm}
onConfirm={stopSession}
/>
{renameTarget && (
<RenameDialog
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={(name) => renameSessionFromDialog(renameTarget.id, name)}
/>
)}
{renameGroupTarget && (
<RenameDialog
open
initial={renameGroupTarget}
onOpenChange={(o) => { 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 && (
<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);
}}
/>
)}
{dwhDown && (
<Dialog open onOpenChange={() => {}}>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connection unavailable</DialogTitle>
<DialogDescription>
The database is unreachable. Check the VPN connection and try again.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
disabled={dwhChecking}
onClick={() => setDwhCheckEpoch((e) => e + 1)}
>
{dwhChecking ? "Checking…" : "Retry"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</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 database through a guided,
human-in-the-loop workflow. Type your question in the box below to begin
a session.
</p>
</div>
);
}